From 1adf5f6ead9856a0fb6d439c8f82ee18d2c94f7a Mon Sep 17 00:00:00 2001 From: Hakunm <275509506+Hakunm@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:57:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(maisaka):=20=E4=BF=9D=E6=8C=81=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8=E8=BD=AE=E6=AC=A1=E7=9A=84=20user?= =?UTF-8?q?=20=E5=89=8D=E7=BD=AE=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maisaka/test_chat_loop_day_boundary.py | 22 ++- pytests/maisaka/test_context_history.py | 143 +++++++++++++++++- src/maisaka/chat_loop_service.py | 35 ++++- src/maisaka/context/history.py | 44 ++++-- src/maisaka/context/post_processor.py | 33 ++-- 5 files changed, 233 insertions(+), 44 deletions(-) diff --git a/pytests/maisaka/test_chat_loop_day_boundary.py b/pytests/maisaka/test_chat_loop_day_boundary.py index ea489b29af..e7168e6166 100644 --- a/pytests/maisaka/test_chat_loop_day_boundary.py +++ b/pytests/maisaka/test_chat_loop_day_boundary.py @@ -86,6 +86,11 @@ def test_application_history_envelope_keeps_one_stable_item_identity() -> None: def test_day_boundary_is_deferred_until_after_tool_result() -> None: history: List[LLMContextMessage] = [ + ReferenceMessage( + content="触发工具调用", + timestamp=datetime(2026, 7, 20, 23, 59, 58), + remaining_uses_value=None, + ), *_build_output_history( "调用表情工具", datetime(2026, 7, 20, 23, 59, 59), @@ -108,19 +113,25 @@ def test_day_boundary_is_deferred_until_after_tool_result() -> None: messages = _build_history_messages(history) assert [type(message) for message in messages] == [ + UserMessageItem, AssistantMessageItem, FunctionCallItem, FunctionCallOutputItem, UserMessageItem, UserMessageItem, ] - assert messages[2].call_id == "call_emoji" - assert get_item_text(messages[3]) == "时间:2026-07-21 00:00:01" - assert get_item_text(messages[4]) == "[参考消息]\n工具后的普通消息" + assert messages[3].call_id == "call_emoji" + assert get_item_text(messages[4]) == "时间:2026-07-21 00:00:01" + assert get_item_text(messages[5]) == "[参考消息]\n工具后的普通消息" def test_day_boundary_is_deferred_until_after_all_tool_results() -> None: history: List[LLMContextMessage] = [ + ReferenceMessage( + content="触发并行工具调用", + timestamp=datetime(2026, 7, 20, 23, 59, 58), + remaining_uses_value=None, + ), *_build_output_history( "调用多个工具", datetime(2026, 7, 20, 23, 59, 59), @@ -145,6 +156,7 @@ def test_day_boundary_is_deferred_until_after_all_tool_results() -> None: messages = _build_history_messages(history) assert [type(message) for message in messages] == [ + UserMessageItem, AssistantMessageItem, FunctionCallItem, FunctionCallItem, @@ -152,8 +164,8 @@ def test_day_boundary_is_deferred_until_after_all_tool_results() -> None: FunctionCallOutputItem, UserMessageItem, ] - assert [message.call_id for message in messages[3:5]] == ["call_first", "call_second"] - assert get_item_text(messages[5]) == "时间:2026-07-21 00:00:01" + assert [message.call_id for message in messages[4:6]] == ["call_first", "call_second"] + assert get_item_text(messages[6]) == "时间:2026-07-21 00:00:01" def test_day_boundary_stays_before_regular_context_message() -> None: diff --git a/pytests/maisaka/test_context_history.py b/pytests/maisaka/test_context_history.py index bcf82c2589..7e20a6cf81 100644 --- a/pytests/maisaka/test_context_history.py +++ b/pytests/maisaka/test_context_history.py @@ -1,21 +1,30 @@ from datetime import datetime +import pytest + +from src.common.data_models.message_component_data_model import MessageSequence, TextComponent from src.llm_models.payload_content.context_item import ( AssistantMessageItem, ContextItemMeta, ContextTextPart, ContextToolCall, FunctionCallItem, + FunctionCallOutputItem, ReasoningItem, ReasoningRepresentation, + SystemMessageItem, + UserMessageItem, ) from src.maisaka.context.history import ( drop_unanswered_tool_calls, normalize_tool_call_result_pairs, normalize_tool_result_order, ) -from src.maisaka.context.messages import ModelOutputContextMessage, ToolResultMessage -from src.maisaka.context.post_processor import _build_trimmed_assistant_tool_user_message +from src.maisaka.context.messages import ModelOutputContextMessage, SessionBackedMessage, ToolResultMessage +from src.maisaka.context.post_processor import ( + _build_trimmed_assistant_tool_user_message, + _trim_history_to_context_target, +) from src.maisaka.chat_loop_service import MaisakaChatLoopService @@ -53,15 +62,21 @@ def _result(call_id: str, logical_turn_id: str = "turn-1") -> ToolResultMessage: ) +def _user(content: str) -> SessionBackedMessage: + return SessionBackedMessage( + raw_message=MessageSequence([TextComponent(content)]), + visible_text=content, + timestamp=datetime.now(), + ) + + def test_normalize_tool_result_order_keeps_parallel_calls_together() -> None: first_call = _call("call-item-1", "call-1") second_call = _call("call-item-2", "call-2") first_result = _result("call-1") second_result = _result("call-2") - normalized, moved_count = normalize_tool_result_order( - [first_call, second_call, second_result, first_result] - ) + normalized, moved_count = normalize_tool_result_order([first_call, second_call, second_result, first_result]) assert normalized == [first_call, second_call, first_result, second_result] assert moved_count == 2 @@ -85,9 +100,7 @@ def test_drop_unanswered_parallel_call_removes_entire_tool_turn() -> None: ) result = _result("call-1") - filtered, removed_count = drop_unanswered_tool_calls( - [reasoning, answered_call, unanswered_call, assistant, result] - ) + filtered, removed_count = drop_unanswered_tool_calls([reasoning, answered_call, unanswered_call, assistant, result]) assert removed_count == 1 assert filtered == [] @@ -144,6 +157,120 @@ def test_context_selection_keeps_complete_tool_turn_beyond_window() -> None: assert "tool_turn_overflow" in selection_reason +def test_context_selection_restores_user_anchor_before_tool_turn() -> None: + trigger = _user("触发工具调用") + call = _call("call-item", "call-1") + result = _result("call-1") + trailing = _user("最新消息") + history = [trigger, call, result, trailing] + + selected, _ = MaisakaChatLoopService.select_llm_context_messages( + history, + request_kind="planner", + max_context_size=1, + enable_visual_message=False, + ) + request_items = MaisakaChatLoopService(chat_system_prompt="system")._build_request_messages( + selected, + enable_visual_message=False, + ) + + assert selected == history + assert [type(item) for item in request_items[:5]] == [ + SystemMessageItem, + UserMessageItem, + FunctionCallItem, + FunctionCallOutputItem, + UserMessageItem, + ] + + +def test_context_selection_restores_one_user_anchor_for_parallel_calls() -> None: + trigger = _user("触发并行工具调用") + first_call = _call("call-item-1", "call-1") + second_call = _call("call-item-2", "call-2") + first_result = _result("call-1") + second_result = _result("call-2") + trailing = _user("最新消息") + history = [trigger, first_call, second_call, first_result, second_result, trailing] + + selected, _ = MaisakaChatLoopService.select_llm_context_messages( + history, + request_kind="planner", + max_context_size=1, + enable_visual_message=False, + ) + request_items = MaisakaChatLoopService(chat_system_prompt="system")._build_request_messages( + selected, + enable_visual_message=False, + ) + + assert selected == history + assert [type(item) for item in request_items[:7]] == [ + SystemMessageItem, + UserMessageItem, + FunctionCallItem, + FunctionCallItem, + FunctionCallOutputItem, + FunctionCallOutputItem, + UserMessageItem, + ] + + +@pytest.mark.parametrize("max_context_size", [1, 2, 3, 4]) +def test_context_selection_keeps_tool_turn_anchors_across_window_boundaries( + max_context_size: int, +) -> None: + history = [ + _user("第一轮触发消息"), + _call("call-item-1", "call-1", "turn-1"), + _result("call-1", "turn-1"), + _user("第二轮触发消息"), + _call("call-item-2", "call-2", "turn-2"), + _result("call-2", "turn-2"), + _user("最新消息"), + ] + + selected, _ = MaisakaChatLoopService.select_llm_context_messages( + history, + request_kind="planner", + max_context_size=max_context_size, + enable_visual_message=False, + ) + request_items = MaisakaChatLoopService(chat_system_prompt="system")._build_request_messages( + selected, + enable_visual_message=False, + ) + + assert isinstance(request_items[1], UserMessageItem) + assert sum(isinstance(item, FunctionCallItem) for item in request_items) == sum( + isinstance(item, FunctionCallOutputItem) for item in request_items + ) + + +def test_history_trimming_removes_user_anchor_and_tool_turn_atomically() -> None: + trigger = _user("触发工具调用") + call = _call("call-item", "call-1") + result = _result("call-1") + trailing = _user("最新消息") + history = [trigger, call, result, trailing] + + removed = _trim_history_to_context_target(history, target_context_count=2) + + assert removed == [trigger, call, result] + assert history == [trailing] + + +def test_request_rejects_function_call_history_without_user_anchor() -> None: + service = MaisakaChatLoopService(chat_system_prompt="system") + + with pytest.raises(ValueError, match="function call 缺少前置 user/function output 锚点"): + service._build_request_messages( + [_call("call-item", "call-1"), _result("call-1")], + enable_visual_message=False, + ) + + def test_history_protocol_removes_both_turns_when_call_and_output_turns_mismatch() -> None: call = _call("call-item", "call-1", "turn-call") result = _result("call-1", "turn-output") diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index eea690305e..c881986e1a 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -22,9 +22,11 @@ CONTEXT_ITEM_SCHEMA_VERSION, ContextItem, ContextItemBuilder, + FunctionCallItem, FunctionCallOutputItem, ProviderActivityItem, RoleType, + UserMessageItem, bind_output_items_to_turn, get_response_reasoning, get_response_text, @@ -44,7 +46,7 @@ from src.services.llm_service import LLMServiceClient from src.maisaka.builtin_tool import get_builtin_tools -from src.maisaka.context.history import normalize_tool_call_result_pairs +from src.maisaka.context.history import collect_tool_turn_anchor_indices, normalize_tool_call_result_pairs from src.maisaka.context.messages import ( LLMContextMessage, ModelOutputContextMessage, @@ -972,14 +974,26 @@ def _build_request_messages( normalized_final_user_message = str(final_user_message or "").strip() if normalized_final_user_message: items.append( - ContextItemBuilder() - .set_role(RoleType.User) - .add_text_content(normalized_final_user_message) - .build() + ContextItemBuilder().set_role(RoleType.User).add_text_content(normalized_final_user_message).build() ) + self._validate_function_call_context_anchors(items) return items + @staticmethod + def _validate_function_call_context_anchors(items: Sequence[ContextItem]) -> None: + """禁止请求历史从缺少 user/function output 锚点的工具调用开始。""" + + has_function_call_anchor = False + for item in items: + if isinstance(item, (UserMessageItem, FunctionCallOutputItem)): + has_function_call_anchor = True + continue + if isinstance(item, FunctionCallItem) and not has_function_call_anchor: + raise ValueError( + f"请求上下文中的 function call 缺少前置 user/function output 锚点: call_id={item.tool_call.call_id}" + ) + async def chat_loop_step( self, chat_history: List[LLMContextMessage], @@ -1016,9 +1030,7 @@ async def chat_loop_step( include_day_boundary_time_messages=request_kind == "planner", injected_user_messages=injected_user_messages, tail_user_messages=tail_user_messages, - final_user_message=( - self._build_planner_final_user_reminder() if request_kind == "planner" else None - ), + final_user_message=(self._build_planner_final_user_reminder() if request_kind == "planner" else None), system_prompt=system_prompt, ) if enable_visual_message: @@ -1302,6 +1314,13 @@ def _expand_selected_tool_turns( if (logical_turn_id := MaisakaChatLoopService._get_history_logical_turn_id(message)) in tool_turn_ids } selected_ids = {id(message) for message in selected_history} + anchor_index_by_turn_id = collect_tool_turn_anchor_indices(list(full_history), selected_turn_ids) + + # logical_turn_id 只绑定模型输出和工具结果;窗口命中工具轮次时,还必须补回 + # 该轮次之前最近的真实 user 上下文,避免请求从 function call 开始。 + for anchor_index in anchor_index_by_turn_id.values(): + selected_ids.add(id(full_history[anchor_index])) + return [ message for message in full_history diff --git a/src/maisaka/context/history.py b/src/maisaka/context/history.py index 58f3363058..cf906393ca 100644 --- a/src/maisaka/context/history.py +++ b/src/maisaka/context/history.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, cast from src.common.data_models.message_component_data_model import MessageSequence, ReplyComponent, TextComponent -from src.llm_models.payload_content.context_item import ContextItem, FunctionCallItem +from src.llm_models.payload_content.context_item import ContextItem, FunctionCallItem, RoleType from src.llm_models.payload_content.context_protocol import ( analyze_context_item_relations, prune_context_items_for_history, @@ -159,6 +159,29 @@ def normalize_tool_call_result_pairs( } +def collect_tool_turn_anchor_indices( + chat_history: list[LLMContextMessage], + logical_turn_ids: set[str], +) -> dict[str, int]: + """定位每个工具轮次之前最近的 user 上下文索引。""" + + first_turn_index_by_id: dict[str, int] = {} + for index, message in enumerate(chat_history): + logical_turn_id = _get_logical_turn_id(message) + if logical_turn_id in logical_turn_ids and logical_turn_id not in first_turn_index_by_id: + first_turn_index_by_id[logical_turn_id] = index + + anchor_index_by_turn_id: dict[str, int] = {} + for logical_turn_id, first_turn_index in first_turn_index_by_id.items(): + anchor_index = next( + (index for index in range(first_turn_index - 1, -1, -1) if chat_history[index].role == RoleType.User.value), + None, + ) + if anchor_index is not None: + anchor_index_by_turn_id[logical_turn_id] = anchor_index + return anchor_index_by_turn_id + + def drop_unanswered_tool_calls( chat_history: list[LLMContextMessage], *, @@ -196,8 +219,7 @@ def drop_unanswered_tool_calls( for message in chat_history if _get_logical_turn_id(message) not in invalid_turn_ids and not ( - isinstance(message, ModelOutputContextMessage) - and message.output_item.meta.item_id in unanswered_item_ids + isinstance(message, ModelOutputContextMessage) and message.output_item.meta.item_id in unanswered_item_ids ) ] return filtered_history, len(unanswered_messages) @@ -230,11 +252,7 @@ def drop_invalid_tool_turns( if not invalid_turn_ids: return chat_history, 0 - filtered_history = [ - message - for message in chat_history - if _get_logical_turn_id(message) not in invalid_turn_ids - ] + filtered_history = [message for message in chat_history if _get_logical_turn_id(message) not in invalid_turn_ids] return filtered_history, len(chat_history) - len(filtered_history) @@ -337,16 +355,14 @@ def normalize_tool_result_order( output_indexes.append(cursor) cursor += 1 - output_messages = [cast(ModelOutputContextMessage, chat_history[output_index]) for output_index in output_indexes] + output_messages = [ + cast(ModelOutputContextMessage, chat_history[output_index]) for output_index in output_indexes + ] for output_index, output_message in zip(output_indexes, output_messages, strict=True): consumed_indexes.add(output_index) normalized_history.append(output_message) - tool_calls = [ - tool_call - for output_message in output_messages - for tool_call in output_message.tool_calls - ] + tool_calls = [tool_call for output_message in output_messages for tool_call in output_message.tool_calls] appended_tool_result_count = 0 for tool_call in tool_calls: tool_call_id = str(tool_call.call_id or "").strip() diff --git a/src/maisaka/context/post_processor.py b/src/maisaka/context/post_processor.py index cec434fe4f..c203b134d9 100644 --- a/src/maisaka/context/post_processor.py +++ b/src/maisaka/context/post_processor.py @@ -8,7 +8,11 @@ from src.common.data_models.message_component_data_model import MessageSequence, TextComponent from src.maisaka.memory.mid_term import is_mid_term_memory_message -from .history import drop_leading_orphan_tool_results, normalize_tool_call_result_pairs +from .history import ( + collect_tool_turn_anchor_indices, + drop_leading_orphan_tool_results, + normalize_tool_call_result_pairs, +) from .messages import ( ComplexSessionMessage, FOCUS_WAKEUP_SOURCE_KINDS, @@ -171,10 +175,7 @@ def _trim_assistant_history_to_latest( ] if not unit_indexes: continue - unit_messages = [ - cast(ModelOutputContextMessage, chat_history[index]) - for index in unit_indexes - ] + unit_messages = [cast(ModelOutputContextMessage, chat_history[index]) for index in unit_indexes] folded_message = _build_trimmed_assistant_tool_user_message( unit_messages, tool_result_by_call_id=tool_result_by_call_id, @@ -339,20 +340,34 @@ def _trim_history_to_context_target( remove_indexes: list[int] = [] visited_indexes: set[int] = set() tool_turn_ids = _collect_tool_turn_ids(chat_history) + anchor_index_by_turn_id = collect_tool_turn_anchor_indices(chat_history, tool_turn_ids) + turn_ids_by_anchor_index: dict[int, set[str]] = {} + for logical_turn_id, anchor_index in anchor_index_by_turn_id.items(): + turn_ids_by_anchor_index.setdefault(anchor_index, set()).add(logical_turn_id) + for index, message in enumerate(chat_history): if index in visited_indexes: continue if is_mid_term_memory_message(message): continue - unit_indexes = [index] + unit_indexes = {index} + unit_turn_ids = set(turn_ids_by_anchor_index.get(index, set())) logical_turn_id = _get_logical_turn_id(message) if logical_turn_id in tool_turn_ids: - unit_indexes = [ + anchor_index = anchor_index_by_turn_id.get(logical_turn_id) + if anchor_index is not None: + unit_indexes.add(anchor_index) + unit_turn_ids.update(turn_ids_by_anchor_index.get(anchor_index, set())) + else: + unit_turn_ids.add(logical_turn_id) + + if unit_turn_ids: + unit_indexes.update( candidate_index for candidate_index, candidate in enumerate(chat_history) - if _get_logical_turn_id(candidate) == logical_turn_id - ] + if _get_logical_turn_id(candidate) in unit_turn_ids + ) visited_indexes.update(unit_indexes) remove_indexes.extend(unit_indexes)