AnoobFeng 446fa03801
fix(context): resolve context compress bug (#4065)
* fix(runtime): persist original human input outside model sanitization

* refactor(history): load thread messages by global event sequence

* fix(frontend): make summarization rescue a transient history bridge

* fix(frontend): old message not append tail

1. add identity anchor
2. add bridgeOrder

* fix(frontend): lint error fix

* fix: address review feedback and harden pagination coverage

- defer transient history ref writes until after render commit
- cover large middleware-only history scans
- verify infinite-query refetch recalculates page cursors
- document AI event types and anchor-weaving differences

* fix: harden message pagination and enrichment

- append unmatched live tails after canonical history
- warn and stop when pagination has_more lacks a cursor
- deep-copy restored UI messages to isolate model-facing content
- log invalid event sequence and non-advancing cursor errors
- pass user_id explicitly through event-store history queries
- cover middleware-only AI runs across memory, JSONL, and DB stores

* fix: address pagination review feedback

* fix(frontend): checkpoint has unknow redener content, optimize the anchor policy

* fix(frontend): unit test issue missed previously, remove the TanStack cache trimming

* fix(gateway): harden message history queries and provenance

- reject externally forged original_user_content metadata
- validate provenance metadata in upload and sanitization middleware
- make run lookups fail closed by default
- batch feedback queries by run ID
- align memory message filtering with persistent stores
2026-07-14 10:43:13 +08:00

151 lines
6.0 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from typing import Any
from langchain_core.messages import HumanMessage
ORIGINAL_USER_CONTENT_KEY = "original_user_content"
SUMMARY_MESSAGE_NAME = "summary"
def message_content_to_text(content: Any) -> str:
"""Extract text from LangChain message content shapes."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(part for part in parts if part)
return str(content)
def message_to_text(message: Any, *, text_attribute_fallback: bool = False) -> str:
"""Extract display text from a whole message (``BaseMessage`` or dict-shaped).
Reads ``content`` from either an attribute (``BaseMessage``) or a mapping key
(``run_events`` rows are dicts), then walks the mixed ``content`` shapes:
plain string; a list of string / ``{"text": ...}`` / nested ``{"content": ...}``
blocks joined without a separator; or a mapping with a ``text``/``content`` key.
Set ``text_attribute_fallback=True`` to fall back to ``message.text`` when
content yields nothing (matches ``RunJournal._message_text``).
Unlike :func:`message_content_to_text` (which takes raw ``content`` and joins
list blocks with newlines), this keeps the no-separator join and the broader
shape handling that several call sites had each reimplemented.
"""
content = message.get("content") if isinstance(message, Mapping) else getattr(message, "content", None)
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, Mapping):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
else:
nested = block.get("content")
if isinstance(nested, str):
parts.append(nested)
return "".join(parts)
if isinstance(content, Mapping):
for key in ("text", "content"):
value = content.get(key)
if isinstance(value, str):
return value
if text_attribute_fallback:
text = getattr(message, "text", None)
if isinstance(text, str):
return text
return ""
def get_original_user_content_text(content: Any, additional_kwargs: Mapping[str, Any] | None) -> str:
"""Return pre-middleware user text when available, otherwise content text."""
original_content = (additional_kwargs or {}).get(ORIGINAL_USER_CONTENT_KEY)
if isinstance(original_content, str):
return original_content
return message_content_to_text(content)
def restore_original_human_message(message: HumanMessage) -> HumanMessage:
"""Build the UI-facing copy of a model-sanitized human message.
Input middleware intentionally keeps the original user text in
``additional_kwargs`` while replacing the model-facing text with transport
wrappers and other context. Run-event history must persist the original
text without mutating the message that is actually sent to the model.
Mixed content is already normalized by the sanitization middleware to a
single text block. For defensive compatibility, multiple current text
blocks are collapsed at the first text position while every non-text block
retains its value and relative order.
"""
original_content = message.additional_kwargs.get(ORIGINAL_USER_CONTENT_KEY)
if not isinstance(original_content, str):
return message
additional_kwargs = dict(message.additional_kwargs)
additional_kwargs.pop(ORIGINAL_USER_CONTENT_KEY, None)
content = message.content
if isinstance(content, str):
restored_content: str | list = original_content
elif isinstance(content, list):
restored_content = []
restored_text = False
for block in content:
is_string_text = isinstance(block, str)
is_mapping_text = isinstance(block, Mapping) and block.get("type") == "text" and isinstance(block.get("text"), str)
if not is_string_text and not is_mapping_text:
restored_content.append(block)
continue
if restored_text:
continue
if is_mapping_text:
restored_content.append({**block, "text": original_content})
else:
restored_content.append(original_content)
restored_text = True
if not restored_text:
restored_content.insert(0, {"type": "text", "text": original_content})
else:
restored_content = original_content
return message.model_copy(
update={
# Pydantic deep-copies the original model for ``deep=True``, but
# applies values supplied through ``update`` without copying them.
# Keep the persisted/UI copy fully isolated from the model-facing
# message, including nested image/file blocks and metadata.
"content": deepcopy(restored_content),
"additional_kwargs": deepcopy(additional_kwargs),
},
deep=True,
)
def is_real_user_message(message: object) -> bool:
"""Return whether ``message`` is a real user-authored HumanMessage.
Middleware-injected hidden HumanMessages and summarization markers should not
drive user-intent features such as slash-skill activation or MCP routing.
"""
if not isinstance(message, HumanMessage):
return False
if message.name == SUMMARY_MESSAGE_NAME:
return False
if message.additional_kwargs.get("hide_from_ui"):
return False
return True