| 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 |