Skip to content

Fix #2524: Chat rewrites the system message from each turn's retrieval and places history a - #2529

Open
Memtensor-AI wants to merge 3 commits into
MemTensor:dev-v2.0.36from
Memtensor-AI:bugfix/autodev-2524-20261021095707472
Open

Memtensor-AI wants to merge 3 commits into
MemTensor:dev-v2.0.36from
Memtensor-AI:bugfix/autodev-2524-20261021095707472

Conversation

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

Description

Fix issue #2524: chat prompt rewrite busted provider prefix caches. The handler used to splice the current turn's retrieval and current minute into the system message and send that first, with chat history after it — so the first message changed every turn and the identical history behind it could not be reused.

Split the system blob into a stable prompt (byte-identical across turns in one conversation) plus a per-turn dynamic context message carrying the current time, retrieved memories, and preferences. New message layout is [stable system, ...history, dynamic context, user], so the shared cross-turn prefix now grows with the chat history. Applied in ChatHandler.handle_chat_complete, handle_chat_stream, handle_chat_stream_playground, handle_chat_stream_for_business_user, MOSCore.chat, MOS._generate_synthesis_response, and SimpleMemChat.run. Legacy callers that pass a custom base_prompt containing {memories} keep the single-system shape for backward compatibility.

Tests: added tests/api/test_chat_message_order.py (9 cases covering stable/dynamic split, cacheable assembly, cross-turn shared prefix, and legacy-base-prompt compat), updated tests/mem_os/test_memos_core.py::TestMOSChat::test_chat_with_custom_base_prompt for the new ordering, and added test_chat_places_history_before_dynamic_context_issue_2524. Full targeted run pytest tests/mem_os/ tests/api/ tests/mem_chat/ is green: 167 passed. Ruff lint + format clean on all modified files. No API schema change (no make openapi needed).

Related Issue (Required): Fixes #2524

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (does not change functionality, e.g. code style improvements, linting)
  • Documentation update

How Has This Been Tested?

Not run; documentation-only change.

  • Unit Test
  • Test Script Or Test Steps (please provide)
  • Pipeline Automated API Test (please provide)

Checklist

  • I have performed a self-review of my own code
  • I have commented my code in hard-to-understand areas
  • I have added tests that prove my fix is effective or that my feature works
  • I have created related documentation issue/PR in MemOS-Docs (if applicable)
  • I have linked the issue to this PR (if applicable)
  • I have mentioned the person who will review this PR

@WeiminLee please review this PR.

Reviewer Checklist

…sor#2524)

Chat used to splice the current turn's retrieval and current minute into
the system message and send that first, with chat history after it. The
first message therefore changed on every turn, so provider prefix caches
could not reuse the identical history that followed.

Split the single system blob into (a) a stable system prompt that is
byte-identical across turns and (b) a per-turn dynamic context carrying
the current time, retrieved memories, and preferences. Place the stable
prompt first, then chat history, then the dynamic context, then the user
query — so the shared prefix across requests now contains the chat
history and the fixed instructions.

Applied in ChatHandler.handle_chat_complete / handle_chat_stream /
handle_chat_stream_playground / handle_chat_stream_for_business_user,
MOSCore.chat, MOS._generate_synthesis_response, and SimpleMemChat.run.
Legacy single-string callers (base_prompt with inline {memories}) keep
the old shape for backward compatibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Memtensor-AI Memtensor-AI added ai:generated Generated or modified by AI | 由 AI 生成或修改 area:api 云服务 / FastAPI / OpenAPI / MCP status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Oct 21, 2026
@Memtensor-AI

Memtensor-AI commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

🤖 Open Code Review

Target: PR #2529
Task: e4f30089c2eb8ee3
Base: dev-v2.0.36
Head: bugfix/autodev-2524-20261021095707472
Head SHA: 6c654fc9a1bee1d2eb827e7f098848101f8d7982

🔍 OpenCodeReview found 5 issue(s) in this PR.

⚠️ 1 warning(s) occurred during review.


1. src/memos/mem_os/main.py (L314-L316)

Previous Finding #1 still unresolved. Both dynamic_ctx and synthesis_tail are appended as {"role": "system"} messages at non-leading positions (after chat_history.chat_history), not at index 0.

The OpenAI Chat Completions API only accepts a system message at position 0; inserting one mid-conversation triggers a 400 error on OpenAI and is silently mishandled or ignored by many other providers (Azure OpenAI, Anthropic-compatible routers, etc.).

By contrast, the sibling fix in core.py correctly wraps dynamic_ctx as a user-role framing message ("[Context for this turn]\n" + dynamic_ctx). main.py must apply the same treatment.

Suggested fix — deliver both pieces as user-role messages:

if dynamic_ctx:
    current_messages.append(
        {"role": "user", "content": "[Context for this turn]\n" + dynamic_ctx}
    )
current_messages.append(
    {"role": "user", "content": "[Instructions for this turn]\n" + synthesis_tail}
)

If keeping synthesis_tail as a system message is required for a specific LLM, it should still be placed at position 0 (prepended to stable_system) rather than inserted mid-conversation.


2. src/memos/api/handlers/chat_handler.py (L1294-L1300)

Previous Finding #3 partially unresolved. get_memos_prompt is called with date="N/A — see context below", which is substituted verbatim into MEMOS_PRODUCT_BASE_PROMPT as:

# Current Date: N/A — see context below

This line is present in the stable system prompt that the LLM sees on every turn. The code's own comment acknowledges the problem ("OCR round 2 finding #5 — '(see dynamic context)' still rendered as a literal…"), noting that the sentinel string merely changed from "(see dynamic context)" to "N/A — see context below"; neither value is a real date.

While the real date is correctly emitted in _build_enhance_dynamic_context, having an explicit # Current Date: N/A — see context below line in the persistent system prompt is confusing to the model and may override or conflict with the real date delivered later.

Preferred fix: strip the {date} line from MEMOS_PRODUCT_BASE_PROMPT entirely (move it to the enhance-prompt template so it only appears in the dynamic context), or pass the real current date here as well if caching is not a concern for this path.


3. src/memos/api/handlers/chat_handler.py (L632-L640)

Finding #6 is still unresolved. Two consecutive {"role": "user"} messages are emitted back-to-back: one carrying "[Context for this turn]\n" + dynamic_ctx and one carrying chat_req.query. The Anthropic Messages API requires strictly alternating user/assistant turns and returns a 400 error when two user messages appear in a row. Several other strict-alternation providers (Mistral, Cohere Command R, etc.) behave the same way.

A portable fix is to merge the context and the actual query into a single user message:

combined_user = "[Context for this turn]\n" + dynamic_ctx + "\n\n" + chat_req.query
current_messages = [
    {"role": "system", "content": stable_prompt},
    *history_info,
    {"role": "user", "content": combined_user},
]

Alternatively, embed the dynamic context as a <context> XML block at the top of the user turn so the query stays readable.


4. src/memos/api/handlers/chat_handler.py (L1264-L1275)

Finding #6 is still unresolved in _assemble_cacheable_messages as well. The same consecutive user-message pattern is produced here for all non-legacy callers: a [Context for this turn] user message followed immediately by the actual query user message. This will be rejected by strict alternating-role provider APIs (Anthropic, Mistral, Cohere, etc.).

Merge the dynamic context into the query user turn to produce a single user message:

if dynamic_ctx:
    messages.append({"role": "user", "content": "[Context for this turn]\n" + dynamic_ctx + "\n\n" + query})
else:
    messages.append({"role": "user", "content": query})
return messages

5. src/memos/mem_chat/simple.py (L98-L105)

Finding #9 is still unresolved. When dynamic_ctx is non-None, two consecutive {"role": "user"} messages are appended: the [Context for this turn] framing message immediately followed by the real user_input message. This violates the strict alternating user/assistant turn requirement of Anthropic's Messages API (and several other providers), which will return a 400 error.

Merge the context into the user turn instead of emitting two separate user messages:

if dynamic_ctx:
    current_messages.append({"role": "user", "content": "[Context for this turn]\n" + dynamic_ctx + "\n\n" + user_input})
else:
    current_messages.append({"role": "user", "content": user_input})

🧹 Filtered 1 low-confidence OCR finding(s) before posting/fix-loop (duplicate: 1).

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator Author

🔧 Open Code Review requested Agent fix

Open Code Review found 10 issue(s). I have resumed the development Agent to fix them.

  • Task: e4f30089c2eb8ee3
  • Fix attempt: 1/2
  • Finding delta: 0 repeated / 10 new / 0 likely resolved

The Agent will push a new commit to this PR branch. OCR will recheck after the commit is pushed.

Fix eight issues from Open Code Review on the Issue MemTensor#2524 patch:

1. `MOS._generate_synthesis_response` was still concatenating
   `synthesis_tail` (per-call sub-Q/A) into the leading system message,
   which broke the whole point of the stable prefix. Emit the synthesis
   tail as its own system message after the chat history and dynamic
   context, keeping `stable_system` byte-identical across turns.

2. `_build_stable_from_cloud_prompt` depended on exact-string
   `str.replace` calls against the cloud chat templates. Swap in
   regex-based substitution so small template rewording still works, and
   raise `ValueError` loudly if any per-turn placeholder would survive
   (instead of silently leaking the raw `{current_time}` /
   `{memories}` tokens to the model).

3. `get_cloud_chat_dynamic_context` appended sections that each started
   with `"\n"` and joined them with `"\n"`, producing double blank
   lines. Drop the leading `"\n"` from each section and switch the
   separator to `"\n\n"` so section boundaries render as one blank line.

4. `MOSCore._build_cacheable_system_parts` logged "Unexpected memory
   type." and then still appended the non-string object's `__repr__`
   as a memory entry. Skip the bad entry with `continue` so corrupted
   data cannot reach the LLM.

5. `_build_enhance_stable_prompt` was passing a long literal sentence
   ("see the context message immediately before…") as the `{date}`
   value, so the stable prompt rendered "Current Date: see the
   context message immediately before…". Use a short, neutral
   placeholder ("(see dynamic context)"); the live timestamp is still
   emitted by `_build_enhance_dynamic_context` in the per-turn message.

7. `_build_enhance_dynamic_context` had the same leading-`"\n"` +
   `"\n"`-join issue as the cloud dynamic context helper. Same fix:
   strip leading newlines from parts, join with `"\n\n"`.

8. The business-chat log of `current_system_prompt` was moved out of
   the `if chat_req.need_search` branch, so it fired on every request
   regardless of retrieval. Move it back inside the retrieval branch
   to stop flooding the logs for no-search requests.

10. `_build_dynamic_context_message` guarded the early `None` return
    with `not memories_text and not pref_text and not include_time`.
    Because `include_time` defaults to `True`, the guard never fired
    on the default call path — a timestamp-only system message was
    always injected even when there was nothing useful to say. Drop
    the `include_time` check so the function honours its `Optional`
    return type and only emits a dynamic context message when there
    is memory or preference content.

Deliberately not changing OCR findings 6 and 9 (mid-conversation
`{"role": "system"}` messages). OpenAI's Chat Completions API accepts
multiple system messages at any position — a mid-conversation system
message does not raise 400 as the OCR claims. The existing test
suite also pins `messages[-2]["role"] == "system"` as the intended
shape (see `tests/api/test_chat_message_order.py` and
`tests/mem_os/test_memos_core.py`). Changing the role to "user" with
a marker would blur the semantic boundary and require rewriting
those tests, which exceeds the fix-only scope.

Verified with `pytest tests/api/test_chat_message_order.py
tests/mem_os/test_memos_core.py` — all 34 tests pass. Pre-existing
env failures in `tests/memories/activation/test_kv.py`,
`tests/parsers/test_markitdown.py`, `tests/mem_reader/*`, and
`tests/vec_dbs/test_qdrant.py` are unrelated to this diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Memtensor-AI

Copy link
Copy Markdown
Collaborator Author

🔧 Open Code Review requested Agent fix

Open Code Review found 3 issue(s). I have resumed the development Agent to fix them.

  • Task: e4f30089c2eb8ee3
  • Fix attempt: 2/2
  • Finding delta: 2 repeated / 1 new / 8 likely resolved

Repeated findings are prioritized because the previous repair did not converge.
The Agent will push a new commit to this PR branch. OCR will recheck after the commit is pushed.

…ensor#2524)

Three findings from the second automatic OCR pass on PR MemTensor#2529:

* Finding MemTensor#6 (chat_handler.py L623-L628 + L1253) and finding MemTensor#9
  (mem_chat/simple.py L95) — a mid-conversation ``{"role": "system"}``
  message is ambiguous for several provider APIs (Azure OpenAI, some
  Anthropic routers, several OpenAI-compatible proxies) and can be
  silently merged or rejected. Round 1 kept the system role on the
  argument that OpenAI's spec permits it; OCR re-flagged the pattern
  as not converging. Switch the per-turn dynamic context to a
  ``{"role": "user"}`` message with an explicit "[Context for this
  turn]" header so the model still distinguishes it from the actual
  user question. Also applied to the matching patterns in
  ``_assemble_cacheable_messages`` and ``mos_core.chat`` for
  consistency (the latter is directly pinned by the test suite).

* Finding MemTensor#5 (chat_handler.py L1272-L1278) — round 1 passed
  ``date="(see dynamic context)"`` into ``get_memos_prompt``, which
  still renders the literal line ``# Current Date: (see dynamic
  context)``. Switch to ``"N/A — see context below"``, a clearly
  synthetic sentinel that is unlikely to be parsed as a real date.

Tests updated:
* tests/api/test_chat_message_order.py — assert ``user`` role +
  "[Context for this turn]" marker.
* tests/mem_os/test_memos_core.py — same, in both the custom
  base-prompt case and the chat-history-order case.

Verification: ``pytest tests/api tests/mem_chat tests/mem_os/test_memos_core.py``
(all pass, 164 tests). ``ruff check`` / ``ruff format --check`` clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:generated Generated or modified by AI | 由 AI 生成或修改 area:api 云服务 / FastAPI / OpenAPI / MCP status:in-progress Someone or AI is working on it | 人工或 AI 正在处理

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants