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