From 5efac74f87e6578867a864a148436d512caa037c Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 12 May 2026 23:02:38 +0800 Subject: [PATCH] fix(chat): always persist reasoning_content on assistant event_payload Non-thinking profiles previously wrote model reasoning only as separate event_type=reasoning rows whose event_payload held chunk_index metadata, so JSON inspection showed tool payloads but no reasoning_content key. Store the full merged reasoning blob on the primary assistant row whenever present (same shape as thinking mode). Update instruction-leak filtering to scan event_payload.reasoning_content as well as legacy reasoning rows. Co-authored-by: Cursor --- interfaces/admin/chat_api.py | 18 +++++++++++++----- runtime/direct_loop.py | 20 ++++++-------------- tests/test_chat_api_message_filter.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/interfaces/admin/chat_api.py b/interfaces/admin/chat_api.py index aa38d463..8a611e92 100644 --- a/interfaces/admin/chat_api.py +++ b/interfaces/admin/chat_api.py @@ -238,12 +238,20 @@ def _filter_internal_instruction_user_messages(msgs: list[Any]) -> list[Any]: instruction_texts: set[str] = set() for m in msgs: role = str(getattr(m, "role", "") or "") - event_type = str(getattr(m, "event_type", "") or "") - if role != "assistant" or event_type != "reasoning": + if role != "assistant": continue - instr = _extract_manager_instruction_text(str(getattr(m, "content", "") or "")) - if instr: - instruction_texts.add(instr) + event_type = str(getattr(m, "event_type", "") or "") + candidates: list[str] = [] + if event_type == "reasoning": + candidates.append(str(getattr(m, "content", "") or "")) + ep = _safe_json_object(getattr(m, "event_payload", None)) + rc = str(ep.get("reasoning_content") or "").strip() + if rc: + candidates.append(rc) + for text in candidates: + instr = _extract_manager_instruction_text(text) + if instr: + instruction_texts.add(instr) if not instruction_texts: return msgs filtered: list[Any] = [] diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 43584e72..593378a9 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -1008,7 +1008,6 @@ def _persist_assistant_step( assistant_text: str, reasoning_text: str, llm_tool_calls: list[Any], - thinking_mode_enabled: bool = False, ) -> _LoopStepResult: stored_tool_calls = [] for tc in llm_tool_calls: @@ -1025,16 +1024,11 @@ def _persist_assistant_step( reasoning_full = "\n".join([str(x or "").strip() for x in reasoning_chunks if str(x or "").strip()]).strip() # Keep empty body as-is when model returns nothing and there are no tool calls. # The UI should treat this as an invisible intermediate/final empty response. - if not thinking_mode_enabled: - for idx, chunk in enumerate(reasoning_chunks): - store.add_message( - session_id=session_id, - role="assistant", - content=chunk, - turn_uuid=turn_uuid, - event_type="reasoning", - event_payload={"chunk_index": int(idx), "chunk_count": len(reasoning_chunks)}, - ) + # + # Persist full reasoning on the primary assistant row's ``event_payload.reasoning_content`` for + # both thinking and non-thinking profiles. Previously non-thinking used separate + # ``event_type=reasoning`` rows with only chunk_index in payload, so JSON inspection looked + # like "no reasoning_content anywhere" while tools carried rich ``event_payload``. assistant_row = store.add_message( session_id=session_id, role="assistant", @@ -1042,7 +1036,7 @@ def _persist_assistant_step( tool_calls=stored_tool_calls or None, turn_uuid=turn_uuid, event_type="tool_call" if stored_tool_calls else "assistant_text", - event_payload=({"reasoning_content": reasoning_full} if (thinking_mode_enabled and reasoning_full) else None), + event_payload=({"reasoning_content": reasoning_full} if reasoning_full else None), ) return _LoopStepResult( assistant_text=assistant_body, @@ -1453,7 +1447,6 @@ def run_oclaw_direct_loop( assistant_text=assistant_text, reasoning_text=reasoning_text, llm_tool_calls=llm_tool_calls, - thinking_mode_enabled=bool(getattr(model, "thinking_mode_enabled", False)), ) final_text = step.assistant_text if not step.llm_tool_calls: @@ -1559,7 +1552,6 @@ def run_oclaw_direct_loop( assistant_text=str(getattr(resp, "content", "") or ""), reasoning_text=str(getattr(resp, "reasoning_content", "") or ""), llm_tool_calls=[], - thinking_mode_enabled=bool(getattr(model, "thinking_mode_enabled", False)), ) final_text = step.assistant_text diff --git a/tests/test_chat_api_message_filter.py b/tests/test_chat_api_message_filter.py index 13b80388..e175f640 100644 --- a/tests/test_chat_api_message_filter.py +++ b/tests/test_chat_api_message_filter.py @@ -21,6 +21,22 @@ def test_filter_internal_instruction_user_messages_hides_polluted_user_row() -> assert [str(getattr(x, "event_type", "")) for x in out] == ["reasoning", "assistant_text"] +def test_filter_internal_instruction_user_messages_hides_from_assistant_event_payload() -> None: + polluted_text = "请检查并修复网关启动失败。" + rows = [ + SimpleNamespace(role="user", event_type="user_text", content=polluted_text), + SimpleNamespace( + role="assistant", + event_type="assistant_text", + content="已修复", + event_payload={"reasoning_content": f"任务分配\nspecialist=ops\ninstruction:\n{polluted_text}"}, + ), + ] + out = _filter_internal_instruction_user_messages(rows) + assert len(out) == 1 + assert str(getattr(out[0], "event_type", "")) == "assistant_text" + + def test_filter_internal_instruction_user_messages_keeps_normal_user_rows() -> None: rows = [ SimpleNamespace(role="user", event_type="user_text", content="你好"),