返回 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/event"
9 "reasonix/internal/provider"
10 )
11
12 // TransientUserBlockTags names every block the host prepends to a user turn as
13 // runtime context rather than something the user typed. Previews, titles, and
14 // the rewind picker strip them; a tag missing from this list leaks raw markup
15 // into the UI, which is how <autoresearch-runtime> surfaced in session titles.
16 //
17 // This is the single source of truth: the strip regex is built from it, and
18 // hasLeadingInjectedBlock walks it. Anything that starts prepending a new block
19 // to user turns belongs here.
20 var TransientUserBlockTags = []string{
21 "response-language",
22 "reasoning-language",
23 "memory-update",
24 "background-jobs",
25 "active-goal",
26 "autoresearch-runtime",
27 "hook-context",
28 "capability-route",
29 "interrupted-turn-recovery",
30 "execution-policy",
31 }
32
33 // reTrailingExecutionPolicy matches the host-appended execution-policy block at
34 // the end of a user turn (attributes allowed on the open tag).
35 var reTrailingExecutionPolicy = regexp.MustCompile(`(?s)\n*<execution-policy(?:\s+[^>]*)?>.*?</execution-policy>\s*$`)
36
37 var reTransientUserBlock = buildTransientUserBlockRE(TransientUserBlockTags)
38
39 // buildTransientUserBlockRE matches one leading transient block: an open tag
40 // (with optional attributes), its content, and its own closing tag. The
41 // alternation is generated so the open and close lists cannot drift apart —
42 // spelling them out twice by hand is what let tags go missing from one side.
43 func buildTransientUserBlockRE(tags []string) *regexp.Regexp {
44 alt := strings.Join(tags, "|")
45 return regexp.MustCompile(`(?s)^\s*<(?:` + alt + `)(?:\s+[^>]*)?>.*?</(?:` + alt + `)>\s*\n?`)
46 }
47
48 // stripTrailingDeliveryRuntime removes the exact delivery-runtime marker the
49 // agent appends to user turns in delivery mode (agent.go DeliveryRuntimeMarker).
50 // Unlike the prefix blocks it trails the user text, so preview/title derivation
51 // needs a suffix cut — leaving it produced session titles like
52 // "你是谁? <delivery-run…". The cut is byte-exact rather than a regex: a lazy
53 // pattern anchored at $ would swallow user prose between a literal
54 // "<delivery-runtime>" mention in the text and the real marker at the end.
55 // (The agent never appends the marker when the input already mentions the tag,
56 // so user messages discussing it carry no host suffix at all.)
57 func stripTrailingDeliveryRuntime(s string) string {
58 trimmed := strings.TrimRight(s, " \t\r\n")
59 if cut, ok := strings.CutSuffix(trimmed, DeliveryRuntimeMarker); ok {
60 return strings.TrimRight(cut, " \t\r\n")
61 }
62 return s
63 }
64
65 const memoryCompilerExecutionOpen = "<memory-compiler-execution>"
66
67 var reMemoryCompilerExecution = regexp.MustCompile(`(?s)<memory-compiler-execution>\s*(.*?)\s*</memory-compiler-execution>`)
68
69 // ContainsMemoryCompilerExecution reports whether content includes a Memory v5
70 // execution contract. The Memory v5 compiler was removed, but transcripts
71 // recorded by releases up to v1.17.x may still carry injected contracts in
72 // persisted user messages, so display paths keep unwrapping them. Callers that
73 // prepare user-facing or replayable text should unwrap the block before display
74 // and avoid treating the raw contract as user-authored.
75 func ContainsMemoryCompilerExecution(content string) bool {
76 return strings.Contains(content, memoryCompilerExecutionOpen)
77 }
78
79 // StripTransientUserBlocks removes controller-injected transient XML blocks
80 // from persisted user messages before deriving display text, previews, or
81 // titles. The blocks are sent in user turns so they never affect the stable
82 // prompt prefix, but they should not become user-facing text later.
83 //
84 // The legacy Memory v5 <memory-compiler-execution> block (written by releases
85 // up to v1.17.x before the compiler was removed) is handled differently from
86 // the prepended transient blocks: it did not prefix the user's prompt, it
87 // REPLACED the whole turn, keeping the user's text only in the contract's
88 // source_event field. Dropping it like a prefix block would leave an empty
89 // string, so we unwrap it to the original prompt instead — otherwise old
90 // sessions whose first turn was compiled would show a blank history/sidebar
91 // preview (#5307).
92 func StripTransientUserBlocks(content string) string {
93 s := unwrapMemoryCompilerExecution(content)
94 for {
95 next := reTransientUserBlock.ReplaceAllStringFunc(s, func(string) string {
96 return ""
97 })
98 if next == s {
99 break
100 }
101 s = next
102 }
103 s = stripTrailingDeliveryRuntime(s)
104 s = reTrailingExecutionPolicy.ReplaceAllString(s, "")
105 s = stripTrailingMemoryRecall(s)
106 return strings.TrimLeft(s, " \t\r\n")
107 }
108
109 func stripTrailingMemoryRecall(s string) string {
110 trimmed := strings.TrimRight(s, " \t\r\n")
111 const open = "<memory-recall>"
112 const close = "</memory-recall>"
113 if !strings.HasSuffix(trimmed, close) {
114 return s
115 }
116 if index := strings.LastIndex(trimmed, open); index >= 0 {
117 return strings.TrimRight(trimmed[:index], " \t\r\n")
118 }
119 return s
120 }
121
122 // unwrapMemoryCompilerExecution replaces a <memory-compiler-execution> contract
123 // with the user prompt it was compiled from (the contract's source_event), so
124 // display text and previews show what the user typed rather than the raw IR
125 // JSON or an empty string. Non-contract content is returned unchanged; a
126 // contract without a recoverable source_event collapses to empty, matching the
127 // prior "strip the block" behavior only as a last resort.
128 func unwrapMemoryCompilerExecution(content string) string {
129 // Unwrap to a fixpoint. A long goal loop (the #5342 bug) could re-compile an
130 // echoed contract many times, so source_event nests another full
131 // <memory-compiler-execution> block; each pass peels the outermost layer and
132 // exposes the next. A single (or fixed two) pass leaves raw contract JSON in
133 // the transcript (#5361). maxDepth bounds pathological accretion.
134 const maxDepth = 24
135 for range maxDepth {
136 if !ContainsMemoryCompilerExecution(content) {
137 return content
138 }
139 next := reMemoryCompilerExecution.ReplaceAllStringFunc(content, func(block string) string {
140 m := reMemoryCompilerExecution.FindStringSubmatch(block)
141 if len(m) < 2 {
142 return ""
143 }
144 return memoryCompilerSourceEvent(m[1])
145 })
146 if next == content {
147 break // no complete block matched (e.g. a dangling/truncated tag)
148 }
149 content = next
150 }
151 // Any residual open tag is a dangling/partial/unparseable block the strict
152 // regex can't complete; drop from the first open tag onward so raw contract
153 // JSON is never surfaced. The user's actual text precedes it.
154 if idx := strings.Index(content, memoryCompilerExecutionOpen); idx >= 0 {
155 content = strings.TrimRight(content[:idx], " \t\r\n")
156 }
157 return content
158 }
159
160 // memoryCompilerSourceEvent pulls the original user prompt out of a compiled
161 // execution contract's JSON body. The source_event lives under planner_ir; an
162 // older/looser shape may carry it at the top level, so both are checked.
163 // Returns "" when the body is not the expected JSON or carries no source_event.
164 func memoryCompilerSourceEvent(body string) string {
165 var contract struct {
166 SourceEvent string `json:"source_event"`
167 PlannerIR struct {
168 SourceEvent string `json:"source_event"`
169 } `json:"planner_ir"`
170 }
171 if err := json.Unmarshal([]byte(strings.TrimSpace(body)), &contract); err != nil {
172 return ""
173 }
174 if s := strings.TrimSpace(contract.PlannerIR.SourceEvent); s != "" {
175 return s
176 }
177 return strings.TrimSpace(contract.SourceEvent)
178 }
179
180 // UserPreviewText returns the user-authored part of a persisted user message.
181 func UserPreviewText(content string) string {
182 s := StripTransientUserBlocks(content)
183 s = HandoffTask(s)
184 s = StripTransientUserBlocks(s)
185 return strings.TrimSpace(s)
186 }
187
188 // pasteDisplayLabelPattern matches the standalone label desktop prepends to a
189 // pasted-text turn. It is UI chrome rather than user intent, so title and
190 // preview derivation may remove it without touching inline label mentions.
191 var pasteDisplayLabelPattern = regexp.MustCompile(`^\[(?:已粘贴文本|已貼上文字|Pasted text) #[0-9]+ · [0-9]+ (?:行|lines)\][ \t]*(?:\r?\n)?`)
192
193 // StripPasteDisplayLabel removes one leading desktop pasted-text label while
194 // preserving the remainder byte-for-byte.
195 func StripPasteDisplayLabel(content string) string {
196 return pasteDisplayLabelPattern.ReplaceAllString(content, "")
197 }
198
199 // UserMessageText returns the best user-authored view of a persisted user turn.
200 // New sessions carry the exact raw text explicitly; older sessions fall back to
201 // deterministic wrapper stripping.
202 func UserMessageText(msg provider.Message) string {
203 if msg.RawContent != "" {
204 return strings.TrimSpace(msg.RawContent)
205 }
206 return UserPreviewText(msg.Content)
207 }
208
209 // emitAdmittedUserMessage publishes the display identity of a persisted
210 // user-authored turn message. Host-injected messages stay silent.
211 func emitAdmittedUserMessage(sink event.Sink, user provider.Message) {
212 if IsUserAuthoredTurnMessage(user) {
213 sink.Emit(event.Event{Kind: event.UserMessage, MessageID: user.ID, Text: UserMessageText(user)})
214 }
215 }
216
217 // migrateLegacyProviderContent canonicalizes both historical user-turn shapes:
218 // legacy turns kept provider-visible text only in Content, while early Context
219 // Engine v2 builds inverted Content and ProviderContent. Canonical sessions
220 // keep provider-visible bytes in Content so previous releases replay them
221 // safely, with user-authored text in RawContent for current display/search.
222 func migrateLegacyProviderContent(msgs []provider.Message) []provider.Message {
223 var upgraded []provider.Message
224 for i, msg := range msgs {
225 if msg.Role != provider.RoleUser {
226 continue
227 }
228 switch {
229 case msg.ProviderContent != "":
230 if upgraded == nil {
231 upgraded = append([]provider.Message(nil), msgs...)
232 }
233 if upgraded[i].RawContent == "" {
234 upgraded[i].RawContent = msg.Content
235 }
236 upgraded[i].Content = msg.ProviderContent
237 upgraded[i].ProviderContent = ""
238 case msg.RawContent == "" && hasLegacyProviderWrapper(msg.Content):
239 if upgraded == nil {
240 upgraded = append([]provider.Message(nil), msgs...)
241 }
242 upgraded[i].RawContent = UserPreviewText(msg.Content)
243 }
244 }
245 if upgraded != nil {
246 return upgraded
247 }
248 return msgs
249 }
250
251 func hasLegacyProviderWrapper(content string) bool {
252 if ContainsMemoryCompilerExecution(content) || reTransientUserBlock.MatchString(content) {
253 return true
254 }
255 if stripTrailingDeliveryRuntime(content) != content {
256 return true
257 }
258 stripped := StripTransientUserBlocks(content)
259 return HandoffTask(stripped) != stripped
260 }
261
262 // Auto Guard writes these onto the failed tool result for the model. Older
263 // sessions may still have them persisted as mid-turn user steers; display
264 // paths must hide those so they never appear as the user's own words.
265 const (
266 HostRecoveryGuidanceToolFailedPrefix = "A tool failed. Use read-only diagnosis as needed"
267 HostRecoveryGuidanceTransientPrefix = "The tool timed out or hit a transient execution limit."
268 ReadinessContinuationPrefix = "This turn ended with work still outstanding:"
269 StandardTodoContinuationPrefix = "The current task list still has an in-progress item."
270 // CompletionValidationContinuationPrefix is retained only so legacy
271 // synthetic user messages from pre-Harness sessions remain classified as
272 // host-generated after the completion validator is removed.
273 CompletionValidationContinuationPrefix = "The host could not confirm this turn is complete:"
274 )
275
276 // legacySyntheticUserPrefixes recognizes host messages written before
277 // provider.Message carried durable origin metadata. Current messages never use
278 // this list for control: their origin is stamped at construction time.
279 var legacySyntheticUserPrefixes = []string{
280 "<reasoning-language>",
281 "Plan approved — plan mode is off",
282 "Host final-answer readiness check failed",
283 ReadinessContinuationPrefix,
284 StandardTodoContinuationPrefix,
285 "You are already in the executor phase",
286 "The previous assistant response was interrupted while a tool call",
287 "The previous assistant response was interrupted during streaming",
288 "The previous assistant response was interrupted before visible",
289 "The previous assistant response finished without any visible answer",
290 "<compaction-summary>",
291 "Summary of the later conversation (compacted from here on):",
292 "Summary of earlier conversation (compacted up to here):",
293 "Continue pursuing the active goal",
294 "The agent signaled goal completion and all tasks are marked done.",
295 "Goal signaled complete but issues remain:",
296 "No tool calls in recent turns.",
297 HostRecoveryGuidanceToolFailedPrefix,
298 HostRecoveryGuidanceTransientPrefix,
299 CompletionValidationContinuationPrefix,
300 "This task has reached its ",
301 "Your tool-call round limit (",
302 "The following tools are unavailable in the current workflow phase:",
303 "Auto recovery has reached its limit for this turn.",
304 "Host progress check:",
305 "Host progress redirect:",
306 }
307
308 // IsHostRecoveryGuidance reports model-facing Auto Guard policy text.
309 func IsHostRecoveryGuidance(text string) bool {
310 trimmed := strings.TrimSpace(text)
311 if trimmed == "" {
312 return false
313 }
314 if after, ok := strings.CutPrefix(trimmed, "↪ "); ok {
315 trimmed = strings.TrimSpace(after)
316 }
317 return strings.HasPrefix(trimmed, HostRecoveryGuidanceToolFailedPrefix) ||
318 strings.HasPrefix(trimmed, HostRecoveryGuidanceTransientPrefix)
319 }
320
321 // VisibleSteerText is the user-authored mid-turn steer the transcript may
322 // show. Host Auto Guard policy is not user-authored and must stay hidden.
323 func VisibleSteerText(content string) (string, bool) {
324 text, handled := ReplaySteerText(content)
325 if !handled || text == "" {
326 return "", false
327 }
328 return text, true
329 }
330
331 // ReplaySteerText reports a persisted steer for display replay. handled is
332 // true for any steer; text is empty when host Auto Guard policy must be omitted.
333 func ReplaySteerText(content string) (text string, handled bool) {
334 text, isSteer := SteerText(content)
335 if !isSteer {
336 return "", false
337 }
338 if IsHostRecoveryGuidance(text) {
339 return "", true
340 }
341 return text, true
342 }
343
344 // IsSyntheticUserText is the compatibility classifier for text-only and legacy
345 // callers. New persisted-message callers must use IsHostGeneratedUserMessage.
346 func IsSyntheticUserText(content string) bool {
347 trimmed := strings.TrimSpace(StripTransientUserBlocks(content))
348 if IsHostRecoveryGuidance(trimmed) {
349 return true
350 }
351 steerText, isSteer := SteerText(content)
352 if isSteer {
353 steerText = strings.TrimSpace(steerText)
354 if IsHostRecoveryGuidance(steerText) {
355 return true
356 }
357 }
358 for _, prefix := range legacySyntheticUserPrefixes {
359 if strings.HasPrefix(trimmed, prefix) || (isSteer && strings.HasPrefix(steerText, prefix)) {
360 return true
361 }
362 }
363 return false
364 }
365
366 // IsHostGeneratedUserMessage reports whether a user-role message came from the
367 // host. Explicit provenance is authoritative; only legacy records fall back to
368 // text recognition.
369 func IsHostGeneratedUserMessage(msg provider.Message) bool {
370 if msg.Role != provider.RoleUser {
371 return false
372 }
373 switch msg.Origin {
374 case provider.MessageOriginHost:
375 return true
376 case provider.MessageOriginUser:
377 return false
378 default:
379 return IsSyntheticUserText(msg.Content)
380 }
381 }
382
383 // IsUserAuthoredTurnMessage reports whether a persisted message begins a real
384 // visible user turn. Mid-turn steers are user-authored but do not start turns.
385 func IsUserAuthoredTurnMessage(msg provider.Message) bool {
386 if msg.Role != provider.RoleUser || IsHostGeneratedUserMessage(msg) {
387 return false
388 }
389 content := UserMessageText(msg)
390 if strings.TrimSpace(StripTransientUserBlocks(content)) == "" {
391 return false
392 }
393 // RawContent is the user's exact steer text and deliberately omits the
394 // provider wrapper. Turn classification must inspect stored Content, while
395 // evidence/display continue to prefer RawContent.
396 _, isSteer := SteerText(msg.Content)
397 return !isSteer
398 }
399
399 lines GO