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