diff --git a/prompts/zh-CN/maisaka_chat.prompt b/prompts/zh-CN/maisaka_chat.prompt index f7562d0a..957ca528 100644 --- a/prompts/zh-CN/maisaka_chat.prompt +++ b/prompts/zh-CN/maisaka_chat.prompt @@ -37,4 +37,4 @@ {group_chat_attention_block} -现在,请你输出你对{bot_name}发言的分析,你必须先输出文本内容的分析,然后再进行工具调用,输出json形式的function call: +现在,请你输出你对{bot_name}发言的分析,你必须先输出文本内容的分析,然后再进行工具调用,: diff --git a/prompts/zh-CN/maisaka_timing_gate.prompt b/prompts/zh-CN/maisaka_timing_gate.prompt index add8a06d..57b7b520 100644 --- a/prompts/zh-CN/maisaka_timing_gate.prompt +++ b/prompts/zh-CN/maisaka_timing_gate.prompt @@ -8,7 +8,7 @@ 在当前场景中,不同的人正在互动({bot_name} 也是一位参与的用户),用户也可能正在连续发送消息或彼此互动。 你的任务不是生成对别人可见的发言,也不是直接使用查询类工具,而是判断当前是否应该: - continue:立刻进入下一轮完整思考、搜集信息、回复与其他工具执行 -- wait:再等待一段时间,然后重新判断,可选几秒的等待,也可等待数分钟 +- wait:固定再等待一段时间,时间到后再重新判断;等待期间即使收到新消息也不会提前打断,只会暂存到超时后统一处理 - no_reply:本轮不继续,直接等待新的外部消息 节奏控制规则: diff --git a/pyproject.toml b/pyproject.toml index a0f67c0b..2ad29842 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "maim-message>=0.6.2", "maibot-dashboard==1.0.0.dev2026040439", "maibot-plugin-sdk>=2.3.0", + "matplotlib>=3.10.5", "mcp", "msgpack>=1.1.2", "numpy>=2.2.6", diff --git a/pytests/common_test/test_expression_auto_check_task.py b/pytests/common_test/test_expression_auto_check_task.py deleted file mode 100644 index da8c59e1..00000000 --- a/pytests/common_test/test_expression_auto_check_task.py +++ /dev/null @@ -1,89 +0,0 @@ -"""测试表达方式自动检查任务的数据库读取行为。""" - -from contextlib import contextmanager -from typing import Generator - -import pytest -from sqlalchemy.pool import StaticPool -from sqlmodel import Session, SQLModel, create_engine - -from src.bw_learner.expression_auto_check_task import ExpressionAutoCheckTask -from src.common.database.database_model import Expression - - -@pytest.fixture(name="expression_auto_check_engine") -def expression_auto_check_engine_fixture() -> Generator: - """创建用于表达方式自动检查任务测试的内存数据库引擎。 - - Yields: - Generator: 供测试使用的 SQLite 内存引擎。 - """ - - engine = create_engine( - "sqlite://", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, - ) - SQLModel.metadata.create_all(engine) - yield engine - - -@pytest.mark.asyncio -async def test_select_expressions_uses_read_only_session( - monkeypatch: pytest.MonkeyPatch, - expression_auto_check_engine, -) -> None: - """选择表达方式时应使用只读会话,并在离开会话后安全读取 ORM 字段。""" - - import src.bw_learner.expression_auto_check_task as expression_auto_check_task_module - - with Session(expression_auto_check_engine) as session: - session.add( - Expression( - situation="表达情绪高涨或生理反应", - style="发送💦表情符号", - content_list='["表达情绪高涨或生理反应"]', - count=1, - session_id="session-a", - checked=False, - rejected=False, - ) - ) - session.commit() - - auto_commit_calls: list[bool] = [] - - @contextmanager - def fake_get_db_session(auto_commit: bool = True) -> Generator[Session, None, None]: - """构造带自动提交语义的测试会话工厂。 - - Args: - auto_commit: 退出上下文时是否自动提交。 - - Yields: - Generator[Session, None, None]: SQLModel 会话对象。 - """ - - auto_commit_calls.append(auto_commit) - session = Session(expression_auto_check_engine) - try: - yield session - if auto_commit: - session.commit() - except Exception: - session.rollback() - raise - finally: - session.close() - - monkeypatch.setattr(expression_auto_check_task_module, "get_db_session", fake_get_db_session) - monkeypatch.setattr(expression_auto_check_task_module.random, "sample", lambda entries, _count: list(entries)) - - task = ExpressionAutoCheckTask() - expressions = await task._select_expressions(1) - - assert auto_commit_calls == [False] - assert len(expressions) == 1 - assert expressions[0].id is not None - assert expressions[0].situation == "表达情绪高涨或生理反应" - assert expressions[0].style == "发送💦表情符号" diff --git a/pytests/test_maisaka_tool_logging.py b/pytests/test_maisaka_tool_logging.py new file mode 100644 index 00000000..0216eb83 --- /dev/null +++ b/pytests/test_maisaka_tool_logging.py @@ -0,0 +1,23 @@ +from src.maisaka.chat_loop_service import MaisakaChatLoopService + + +def test_build_tool_names_log_text_supports_openai_function_schema() -> None: + tool_definitions = [ + { + "type": "function", + "function": { + "name": "mute_user", + "description": "禁言指定用户", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, + { + "name": "reply", + "description": "发送回复", + }, + ] + + assert MaisakaChatLoopService._build_tool_names_log_text(tool_definitions) == "mute_user、reply" diff --git a/pytests/test_mute_plugin_sdk.py b/pytests/test_mute_plugin_sdk.py new file mode 100644 index 00000000..24bde7e4 --- /dev/null +++ b/pytests/test_mute_plugin_sdk.py @@ -0,0 +1,160 @@ +"""MutePlugin SDK 迁移回归测试。""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, List + +import pytest + +from maibot_sdk.context import PluginContext +from maibot_sdk.plugin import MaiBotPlugin + +from plugins.MutePlugin.plugin import create_plugin +from src.core.tooling import ToolExecutionContext, ToolInvocation +from src.plugin_runtime.component_query import ComponentQueryService +from src.plugin_runtime.runner.manifest_validator import ManifestValidator + + +def _build_plugin() -> MaiBotPlugin: + """构造已注入默认配置的插件实例。""" + + plugin = create_plugin() + plugin.set_plugin_config(plugin.get_default_config()) + return plugin + + +def test_mute_plugin_manifest_is_valid_v2() -> None: + """MutePlugin 的 manifest 应符合当前运行时要求。""" + + validator = ManifestValidator(host_version="1.0.0", sdk_version="2.3.0") + manifest = validator.load_from_plugin_path(Path("plugins/MutePlugin")) + + assert manifest is not None + assert manifest.id == "sengokucola.mute-plugin" + assert manifest.manifest_version == 2 + + +def test_create_plugin_returns_sdk_plugin() -> None: + """插件入口应返回 SDK 插件实例。""" + + plugin = create_plugin() + + assert isinstance(plugin, MaiBotPlugin) + + +@pytest.mark.asyncio +async def test_mute_command_calls_napcat_group_ban_api() -> None: + """手动禁言命令应通过 NapCat Adapter 新 API 执行。""" + + plugin = _build_plugin() + plugin.set_plugin_config( + { + **plugin.get_default_config(), + "components": { + "enable_smart_mute": True, + "enable_mute_command": True, + }, + } + ) + + capability_calls: List[Dict[str, Any]] = [] + + async def fake_rpc_call(method: str, plugin_id: str = "", payload: Dict[str, Any] | None = None) -> Dict[str, Any]: + assert method == "cap.call" + assert payload is not None + capability_calls.append(payload) + + capability = payload["capability"] + if capability == "person.get_id_by_name": + return {"success": True, "person_id": "person-1"} + if capability == "person.get_value": + return {"success": True, "value": "123456"} + if capability == "api.call": + return {"success": True, "result": {"status": "ok", "retcode": 0}} + if capability == "send.text": + return {"success": True} + raise AssertionError(f"unexpected capability: {capability}") + + plugin._set_context(PluginContext(plugin_id="mute", rpc_call=fake_rpc_call)) + + success, message, intercept = await plugin.handle_mute_command( + stream_id="group-10001", + group_id="10001", + user_id="42", + matched_groups={ + "target": "张三", + "duration": "120", + "reason": "刷屏", + }, + ) + + assert success is True + assert message == "成功禁言 张三" + assert intercept is True + + api_call = next(call for call in capability_calls if call["capability"] == "api.call") + assert api_call["args"]["api_name"] == "adapter.napcat.group.set_group_ban" + assert api_call["args"]["version"] == "1" + assert api_call["args"]["args"] == { + "group_id": "10001", + "user_id": "123456", + "duration": 120, + } + + +@pytest.mark.asyncio +async def test_mute_tool_requires_target_person_name() -> None: + """禁言工具在缺少目标时应直接失败并提示。""" + + plugin = _build_plugin() + capability_calls: List[Dict[str, Any]] = [] + + async def fake_rpc_call(method: str, plugin_id: str = "", payload: Dict[str, Any] | None = None) -> Dict[str, Any]: + assert method == "cap.call" + assert payload is not None + capability_calls.append(payload) + return {"success": True} + + plugin._set_context(PluginContext(plugin_id="mute", rpc_call=fake_rpc_call)) + + success, message = await plugin.handle_mute_tool( + stream_id="group-10001", + group_id="10001", + target="", + duration="60", + reason="测试", + ) + + assert success is False + assert message == "禁言目标不能为空" + assert capability_calls[-1]["capability"] == "send.text" + assert capability_calls[-1]["args"]["text"] == "没有指定禁言对象哦" + + +def test_tool_invocation_payload_injects_group_and_user_context() -> None: + """插件工具执行时应自动补齐群聊上下文字段。""" + + entry = SimpleNamespace(invoke_method="plugin.invoke_tool") + anchor_message = SimpleNamespace( + message_info=SimpleNamespace( + group_info=SimpleNamespace(group_id="10001"), + user_info=SimpleNamespace(user_id="20002"), + ) + ) + invocation = ToolInvocation(tool_name="mute", arguments={"target": "张三"}, stream_id="session-1") + context = ToolExecutionContext( + session_id="session-1", + stream_id="session-1", + reasoning="test", + metadata={"anchor_message": anchor_message}, + ) + + payload = ComponentQueryService._build_tool_invocation_payload(entry, invocation, context) + + assert payload["target"] == "张三" + assert payload["stream_id"] == "session-1" + assert payload["chat_id"] == "session-1" + assert payload["group_id"] == "10001" + assert payload["user_id"] == "20002" diff --git a/pytests/test_openai_client_toolless_request.py b/pytests/test_openai_client_toolless_request.py new file mode 100644 index 00000000..2e1748b7 --- /dev/null +++ b/pytests/test_openai_client_toolless_request.py @@ -0,0 +1,27 @@ +from src.llm_models.model_client.openai_client import _sanitize_messages_for_toolless_request +from src.llm_models.payload_content.message import Message, RoleType, TextMessagePart +from src.llm_models.payload_content.tool_option import ToolCall + + +def test_sanitize_messages_for_toolless_request_drops_assistant_tool_call_without_parts() -> None: + messages = [ + Message( + role=RoleType.Assistant, + tool_calls=[ + ToolCall( + call_id="call_1", + func_name="mute_user", + args={"target": "alice"}, + ) + ], + ), + Message( + role=RoleType.User, + parts=[TextMessagePart(text="继续")], + ), + ] + + sanitized_messages = _sanitize_messages_for_toolless_request(messages) + + assert len(sanitized_messages) == 1 + assert sanitized_messages[0].role == RoleType.User diff --git a/src/learners/expression_auto_check_task.py b/src/learners/expression_auto_check_task.py index 5ab43ba0..60dc9bd1 100644 --- a/src/learners/expression_auto_check_task.py +++ b/src/learners/expression_auto_check_task.py @@ -24,7 +24,7 @@ from src.common.data_models.llm_service_data_models import LLMGenerationOptions from src.services.llm_service import LLMServiceClient from src.manager.async_task_manager import AsyncTask -logger = get_logger("expression_auto_check_task") +logger = get_logger("expressor") def create_evaluation_prompt(situation: str, style: str) -> str: diff --git a/src/llm_models/model_client/openai_client.py b/src/llm_models/model_client/openai_client.py index e5f12998..b4dc0f1e 100644 --- a/src/llm_models/model_client/openai_client.py +++ b/src/llm_models/model_client/openai_client.py @@ -346,6 +346,32 @@ def _convert_assistant_tool_calls(tool_calls: List[ToolCall]) -> List[ChatComple return converted_tool_calls +def _sanitize_messages_for_toolless_request(messages: List[Message]) -> List[Message]: + """在无工具请求时清洗历史工具调用链,避免兼容接口拒收消息。""" + sanitized_messages: List[Message] = [] + + for message in messages: + if message.role == RoleType.Tool: + continue + + if message.role == RoleType.Assistant and message.tool_calls: + if not message.parts: + continue + assistant_message = Message( + role=message.role, + parts=list(message.parts), + tool_call_id=message.tool_call_id, + tool_name=message.tool_name, + tool_calls=None, + ) + sanitized_messages.append(assistant_message) + continue + + sanitized_messages.append(message) + + return sanitized_messages + + def _convert_messages(messages: List[Message]) -> List[ChatCompletionMessageParam]: """将内部消息列表转换为 OpenAI 兼容消息列表。 @@ -965,7 +991,12 @@ class OpenaiClient(AdapterClient[AsyncStream[ChatCompletionChunk], ChatCompletio model_info = request.model_info try: - messages_payload: List[ChatCompletionMessageParam] = _convert_messages(request.message_list) + request_messages = ( + list(request.message_list) + if request.tool_options + else _sanitize_messages_for_toolless_request(request.message_list) + ) + messages_payload: List[ChatCompletionMessageParam] = _convert_messages(request_messages) tools_payload: List[ChatCompletionToolParam] | None = ( _convert_tool_options(request.tool_options) if request.tool_options else None ) diff --git a/src/llm_models/utils_model.py b/src/llm_models/utils_model.py index 613f0b2f..c550f311 100644 --- a/src/llm_models/utils_model.py +++ b/src/llm_models/utils_model.py @@ -397,8 +397,6 @@ class LLMOrchestrator: start_time = time.time() tool_built = self._build_tool_options(tools) - if self.request_type.startswith("maisaka_"): - logger.info(f"LLMOrchestrator[{self.request_type}] 已构建 {len(tool_built or [])} 个内部工具选项") execution_result = await self._execute_request( request_type=RequestType.RESPONSE, diff --git a/src/maisaka/builtin_tool/wait.py b/src/maisaka/builtin_tool/wait.py index 5a9c7149..97bb9e61 100644 --- a/src/maisaka/builtin_tool/wait.py +++ b/src/maisaka/builtin_tool/wait.py @@ -12,8 +12,8 @@ def get_tool_spec() -> ToolSpec: return ToolSpec( name="wait", - brief_description="暂停当前对话并等待用户新的输入。", - detailed_description="参数说明:\n- seconds:integer,必填。等待的秒数。", + brief_description="暂停当前对话并固定等待一段时间,期间不因新消息提前恢复。", + detailed_description="参数说明:\n- seconds:integer,必填。等待的秒数。等待期间收到的新消息只会暂存,直到超时后再继续处理。", parameters_schema={ "type": "object", "properties": { @@ -46,6 +46,6 @@ async def handle_tool( tool_ctx.runtime._enter_wait_state(seconds=wait_seconds, tool_call_id=invocation.call_id) return tool_ctx.build_success_result( invocation.tool_name, - f"当前对话循环进入等待状态,最长等待 {wait_seconds} 秒。", + f"当前对话循环进入等待状态,将固定等待 {wait_seconds} 秒;期间收到的新消息不会提前打断本次等待。", metadata={"pause_execution": True}, ) diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 4f4c8255..77e49f2f 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -475,6 +475,44 @@ class MaisakaChatLoopService: return normalized_text return f"{normalized_text[: max_length - 1]}…" + @staticmethod + def _build_tool_names_log_text(tool_definitions: Sequence[ToolDefinitionInput]) -> str: + """构造 planner 请求前的工具列表日志文本。 + + Args: + tool_definitions: 本轮实际传给 planner 的工具定义列表。 + + Returns: + str: 适合直接写入日志的单行文本。 + """ + + tool_names: List[str] = [] + for tool_definition in tool_definitions: + if not isinstance(tool_definition, dict): + continue + normalized_name = str(tool_definition.get("name") or "").strip() + if not normalized_name: + function_definition = tool_definition.get("function") + if isinstance(function_definition, dict): + normalized_name = str(function_definition.get("name") or "").strip() + if normalized_name: + tool_names.append(normalized_name) + + if not tool_names: + return "[无工具]" + + return "、".join(tool_names) + + @staticmethod + def _build_tool_spec_names_log_text(tool_specs: Sequence[ToolSpec]) -> str: + """构造 ToolSpec 列表的工具名日志文本。""" + + tool_names = [tool_spec.name for tool_spec in tool_specs if tool_spec.name] + if not tool_names: + return "[无工具]" + + return "、".join(tool_names) + def _build_tool_filter_prompt( self, selected_history: List[LLMContextMessage], @@ -620,7 +658,8 @@ class MaisakaChatLoopService: f"总工具数={len(tool_specs)} " f"内置工具数={len(builtin_tool_specs)} " f"候选工具数={len(candidate_tool_specs)} " - f"最多保留候选数={max_keep}" + f"最多保留候选数={max_keep} " + f"过滤前全部工具名={self._build_tool_spec_names_log_text(tool_specs)}" ) try: @@ -660,6 +699,7 @@ class MaisakaChatLoopService: "工具预筛选完成: " f"筛选前总数={len(tool_specs)} " f"筛选后总数={len(filtered_tool_specs)} " + f"过滤后全部工具名={self._build_tool_spec_names_log_text(filtered_tool_specs)} " f"保留候选工具={[tool_spec.name for tool_spec in filtered_candidate_tool_specs]}" ) return filtered_tool_specs @@ -729,6 +769,11 @@ class MaisakaChatLoopService: if isinstance(raw_tool_definitions, list): all_tools = [item for item in raw_tool_definitions if isinstance(item, dict)] + logger.info( + f"规划器工具列表(request_kind={request_kind}): " + f"共 {len(all_tools)} 个 -> {self._build_tool_names_log_text(all_tools)}" + ) + prompt_section: RenderableType | None = None if global_config.debug.show_maisaka_thinking: image_display_mode: str = "path_link" if global_config.maisaka.show_image_path else "legacy" @@ -740,10 +785,11 @@ class MaisakaChatLoopService: selection_reason=selection_reason, image_display_mode=image_display_mode, folded=global_config.debug.fold_maisaka_thinking, + tool_definitions=list(all_tools), ) logger.info( - "规划器请求开始: " + f"规划器请求开始(request_kind={request_kind}): " f"已选上下文消息数={len(selected_history)} " f"大模型消息数={len(built_messages)} " f"工具数={len(all_tools)} " @@ -760,15 +806,6 @@ class MaisakaChatLoopService: ), ) - prompt_stats_text = PromptCLIVisualizer.build_prompt_stats_text( - selected_history_count=len(selected_history), - built_message_count=len(built_messages), - prompt_tokens=generation_result.prompt_tokens, - completion_tokens=generation_result.completion_tokens, - total_tokens=generation_result.total_tokens, - ) - logger.info(f"本轮Prompt统计: {prompt_stats_text}") - final_response = generation_result.response or "" final_tool_calls = list(generation_result.tool_calls or []) after_response_result = await self._get_runtime_manager().invoke_hook( diff --git a/src/maisaka/prompt_cli_renderer.py b/src/maisaka/prompt_cli_renderer.py index 64046b86..d1b8d6ec 100644 --- a/src/maisaka/prompt_cli_renderer.py +++ b/src/maisaka/prompt_cli_renderer.py @@ -242,6 +242,85 @@ class PromptCLIVisualizer: def format_tool_call_for_display(cls, tool_call: Any) -> Dict[str, Any]: return normalize_tool_call_for_display(tool_call) + @classmethod + def _build_tool_card_title(cls, tool_call: Any) -> str: + """构建 HTML 中工具卡片的折叠标题。""" + + normalized_tool_call = cls.format_tool_call_for_display(tool_call) + tool_name = str(normalized_tool_call.get("name") or "").strip() + return tool_name or "unknown" + + @classmethod + def _build_tool_call_html(cls, tool_call: Any) -> str: + """将单个工具调用渲染为默认折叠的 HTML 卡片。""" + + normalized_tool_call = cls.format_tool_call_for_display(tool_call) + tool_name = cls._build_tool_card_title(tool_call) + tool_call_id = str(normalized_tool_call.get("id") or "").strip() + tool_arguments = normalized_tool_call.get("arguments") + + tool_meta_html = "" + if tool_call_id: + tool_meta_html = ( + "
" + ) + + return ( + "{html.escape(json.dumps(tool_arguments, ensure_ascii=False, indent=2, default=str))}"
+ "{html.escape(json.dumps(detail_payload, ensure_ascii=False, indent=2, default=str))}"
+ "{html.escape(json.dumps(normalized_tool_call, ensure_ascii=False, indent=2, default=str))}"
- "