From 066c8baf84839d9e8334f25374cab7dd0172ebc0 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 22 Apr 2026 23:36:39 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E4=BF=AE=E5=A4=8D=E9=83=A8?= =?UTF-8?q?=E5=88=86=E6=A8=A1=E5=9E=8B=E8=AF=B7=E6=B1=82=E9=97=AE=E9=A2=98?= =?UTF-8?q?(v4l=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pytests/test_plugin_runtime.py | 28 ++++++++++ pytests/test_tool_availability.py | 75 +++++++++++++++++++++++++++ src/maisaka/chat_loop_service.py | 3 +- src/maisaka/history_post_processor.py | 6 ++- src/maisaka/history_utils.py | 67 ++++++++++++++++++++++++ src/maisaka/reasoning_engine.py | 11 +++- src/plugin_runtime/host/supervisor.py | 8 +++ 7 files changed, 195 insertions(+), 3 deletions(-) diff --git a/pytests/test_plugin_runtime.py b/pytests/test_plugin_runtime.py index 5c2706b6..d8561f19 100644 --- a/pytests/test_plugin_runtime.py +++ b/pytests/test_plugin_runtime.py @@ -11,6 +11,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence import asyncio import json +import logging import os import sys @@ -610,6 +611,33 @@ class TestSDK: assert plugin.configs == [{"plugin_enabled": True}] assert plugin.updates == [("model", {"models": []}, "", [{"plugin_enabled": True}])] + @pytest.mark.asyncio + async def test_host_logs_runner_ready_plugin_failures(self, caplog): + """Host 收到 runner.ready 时应明确记录插件注册失败。""" + from src.plugin_runtime.host.supervisor import PluginRunnerSupervisor + from src.plugin_runtime.protocol.envelope import Envelope, MessageType + + supervisor = PluginRunnerSupervisor(plugin_dirs=[], runner_spawn_timeout_sec=1) + envelope = Envelope( + request_id=1, + message_type=MessageType.REQUEST, + method="runner.ready", + plugin_id="", + payload={ + "loaded_plugins": ["ok_plugin"], + "failed_plugins": ["bad_plugin"], + "inactive_plugins": ["disabled_plugin"], + }, + ) + + with caplog.at_level(logging.INFO, logger="plugin_runtime.host.runner_manager"): + response = await supervisor._handle_runner_ready(envelope) + + assert response.payload["accepted"] is True + assert "插件注册失败: bad_plugin" in caplog.text + assert "插件未激活: disabled_plugin" in caplog.text + assert "Runner 插件初始化完成: loaded=1 failed=1 inactive=1" in caplog.text + @pytest.mark.asyncio async def test_runner_bootstraps_capabilities_before_on_load(self, monkeypatch): """on_load 期间的 capability 调用应在 bootstrap 后生效。""" diff --git a/pytests/test_tool_availability.py b/pytests/test_tool_availability.py index 1f6368f5..2a025bf0 100644 --- a/pytests/test_tool_availability.py +++ b/pytests/test_tool_availability.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from typing import Any import importlib.util import sys @@ -220,3 +221,77 @@ def test_mute_plugin_exports_allowed_groups_as_component_allowed_session() -> No assert mute_components[0]["chat_scope"] == "group" assert mute_components[0]["allowed_session"] == ["qq:10001", "raw-group-id"] assert "allowed_session" not in mute_components[0]["metadata"] + + +@pytest.mark.asyncio +async def test_mute_tool_queries_target_message_with_current_chat_id() -> None: + module_path = "plugins/MutePlugin/plugin.py" + spec = importlib.util.spec_from_file_location("mute_plugin_under_test_msg_id", module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + module.MutePluginConfig.model_rebuild() + + capability_calls: list[dict[str, Any]] = [] + api_calls: list[dict[str, Any]] = [] + + async def fake_call_capability(name: str, **kwargs: Any) -> dict[str, Any]: + capability_calls.append({"name": name, **kwargs}) + return { + "success": True, + "result": { + "success": True, + "message": { + "message_info": { + "user_info": { + "user_id": "35529667", + "user_cardname": "目标用户", + "user_nickname": "目标昵称", + } + } + }, + }, + } + + async def fake_api_call(api_name: str, **kwargs: Any) -> dict[str, Any]: + api_calls.append({"name": api_name, **kwargs}) + if api_name == "adapter.napcat.group.get_group_member_info": + return {"success": True, "result": {"data": {"role": "member"}}} + return {"status": "ok", "retcode": 0} + + plugin = module.MutePlugin() + plugin.set_plugin_config({"components": {"enable_smart_mute": True}}) + plugin._set_context( + SimpleNamespace( + call_capability=fake_call_capability, + api=SimpleNamespace(call=fake_api_call), + logger=SimpleNamespace(info=lambda *args, **kwargs: None, warning=lambda *args, **kwargs: None), + ) + ) + + success, message = await plugin.handle_mute_tool( + stream_id="current-session-id", + group_id="766798517", + msg_id="2046083292", + duration=3600, + reason="测试", + ) + + assert success is True + assert message == "成功禁言 目标用户" + assert capability_calls == [ + { + "name": "message.get_by_id", + "message_id": "2046083292", + "chat_id": "current-session-id", + } + ] + assert api_calls[-1] == { + "name": "adapter.napcat.group.set_group_ban", + "version": "1", + "group_id": "766798517", + "user_id": "35529667", + "duration": 3600, + } diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 9363a13c..1b915767 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -35,7 +35,7 @@ from .context_messages import ( ToolResultMessage, build_llm_message_from_context, ) -from .history_utils import drop_orphan_tool_results +from .history_utils import drop_orphan_tool_results, normalize_tool_result_order from .display.prompt_cli_renderer import PromptCLIVisualizer from .visual_mode_utils import resolve_enable_visual_planner @@ -652,6 +652,7 @@ class MaisakaChatLoopService: selected_history = [filtered_history[index] for index in selected_indices] selected_history, _ = MaisakaChatLoopService._hide_early_assistant_messages(selected_history) selected_history, _ = drop_orphan_tool_results(selected_history) + selected_history, _ = normalize_tool_result_order(selected_history) tool_message_count = sum(1 for message in selected_history if isinstance(message, ToolResultMessage)) normal_message_count = len(selected_history) - tool_message_count selection_reason = ( diff --git a/src/maisaka/history_post_processor.py b/src/maisaka/history_post_processor.py index 8796b6e8..39df5a31 100644 --- a/src/maisaka/history_post_processor.py +++ b/src/maisaka/history_post_processor.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from .context_messages import AssistantMessage, LLMContextMessage, ToolResultMessage -from .history_utils import drop_leading_orphan_tool_results, drop_orphan_tool_results +from .history_utils import drop_leading_orphan_tool_results, drop_orphan_tool_results, normalize_tool_result_order TIMING_HISTORY_TOOL_NAMES = {"continue", "finish", "no_reply", "wait"} EARLY_TRIM_RATIO = 0.2 @@ -15,6 +15,7 @@ class HistoryPostProcessResult: history: list[LLMContextMessage] removed_count: int + changed_count: int remaining_context_count: int @@ -30,6 +31,7 @@ def process_chat_history_after_cycle( removed_assistant_thought_count = _remove_early_assistant_thoughts(processed_history) processed_history, orphan_removed_count = drop_orphan_tool_results(processed_history) + processed_history, moved_tool_result_count = normalize_tool_result_order(processed_history) remaining_context_count = sum(1 for message in processed_history if message.count_in_context) removed_overflow_count = 0 @@ -48,9 +50,11 @@ def process_chat_history_after_cycle( + orphan_removed_count + removed_overflow_count ) + changed_count = removed_count + moved_tool_result_count return HistoryPostProcessResult( history=processed_history, removed_count=removed_count, + changed_count=changed_count, remaining_context_count=remaining_context_count, ) diff --git a/src/maisaka/history_utils.py b/src/maisaka/history_utils.py index 6b2d086c..e578ffdd 100644 --- a/src/maisaka/history_utils.py +++ b/src/maisaka/history_utils.py @@ -105,3 +105,70 @@ def drop_orphan_tool_results( filtered_history.append(message) return filtered_history, removed_count + + +def normalize_tool_result_order( + chat_history: list[LLMContextMessage], +) -> tuple[list[LLMContextMessage], int]: + """把被其他消息隔开的 tool 结果移动到对应 assistant tool_calls 后面。""" + + if not chat_history: + return chat_history, 0 + + consumed_indexes: set[int] = set() + normalized_history: list[LLMContextMessage] = [] + moved_count = 0 + + for index, message in enumerate(chat_history): + if index in consumed_indexes: + continue + + normalized_history.append(message) + if not isinstance(message, AssistantMessage) or not message.tool_calls: + continue + + appended_tool_result_count = 0 + for tool_call in message.tool_calls: + tool_call_id = str(tool_call.call_id or "").strip() + if not tool_call_id: + continue + + matching_index = _find_tool_result_index( + chat_history, + tool_call_id=tool_call_id, + start_index=index + 1, + consumed_indexes=consumed_indexes, + ) + if matching_index is None: + continue + + consumed_indexes.add(matching_index) + normalized_history.append(chat_history[matching_index]) + expected_index = index + appended_tool_result_count + 1 + if matching_index != expected_index: + moved_count += 1 + appended_tool_result_count += 1 + + if moved_count <= 0: + return chat_history, 0 + return normalized_history, moved_count + + +def _find_tool_result_index( + chat_history: list[LLMContextMessage], + *, + tool_call_id: str, + start_index: int, + consumed_indexes: set[int], +) -> int | None: + """查找指定 tool_call_id 对应的 tool 结果消息位置。""" + + for index in range(start_index, len(chat_history)): + if index in consumed_indexes: + continue + message = chat_history[index] + if not isinstance(message, ToolResultMessage): + continue + if message.tool_call_id == tool_call_id: + return index + return None diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 1f4c5500..53fe4d34 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -341,6 +341,13 @@ class MaisakaReasoningEngine: queued_trigger = await self._runtime._internal_turn_queue.get() message_triggered, timeout_triggered = self._drain_ready_turn_triggers(queued_trigger) + if self._runtime._agent_state == self._runtime._STATE_WAIT and not timeout_triggered: + self._runtime._message_turn_scheduled = False + logger.debug( + f"{self._runtime.log_prefix} 当前仍处于 wait 状态,忽略消息触发并继续等待超时" + ) + continue + if message_triggered: await self._runtime._wait_for_message_quiet_period() self._runtime._message_turn_scheduled = False @@ -809,10 +816,12 @@ class MaisakaReasoningEngine: self._runtime._chat_history, max_context_size=self._runtime._max_context_size, ) - if process_result.removed_count <= 0: + if process_result.changed_count <= 0: return self._runtime._chat_history = process_result.history + if process_result.removed_count <= 0: + return self._runtime._log_history_trimmed( process_result.removed_count, process_result.remaining_context_count, diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index 1864b454..4927a120 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -1306,6 +1306,14 @@ class PluginRunnerSupervisor: return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) self._runner_ready_payloads = payload + if payload.failed_plugins: + logger.error(f"插件注册失败: {', '.join(payload.failed_plugins)}") + if payload.inactive_plugins: + logger.warning(f"插件未激活: {', '.join(payload.inactive_plugins)}") + logger.info( + "Runner 插件初始化完成: " + f"loaded={len(payload.loaded_plugins)} failed={len(payload.failed_plugins)} inactive={len(payload.inactive_plugins)}" + ) self._runner_ready_events.set() return envelope.make_response(payload={"accepted": True})