From 243b8deb43696398763ed84202e20fb656fb3634 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 9 Apr 2026 19:58:20 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=B1=95=E7=A4=BA=E6=9B=B4?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E7=9A=84=E5=B7=A5=E5=85=B7=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=94=B9wait=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prompts/zh-CN/maisaka_chat.prompt | 2 +- prompts/zh-CN/maisaka_timing_gate.prompt | 2 +- pyproject.toml | 1 + .../test_expression_auto_check_task.py | 89 -------- pytests/test_maisaka_tool_logging.py | 23 ++ pytests/test_mute_plugin_sdk.py | 160 +++++++++++++ .../test_openai_client_toolless_request.py | 27 +++ src/learners/expression_auto_check_task.py | 2 +- src/llm_models/model_client/openai_client.py | 33 ++- src/llm_models/utils_model.py | 2 - src/maisaka/builtin_tool/wait.py | 6 +- src/maisaka/chat_loop_service.py | 59 ++++- src/maisaka/prompt_cli_renderer.py | 210 ++++++++++++++++-- src/maisaka/reasoning_engine.py | 43 ++-- src/maisaka/runtime.py | 72 +++--- src/plugin_runtime/component_query.py | 39 +++- 16 files changed, 576 insertions(+), 194 deletions(-) delete mode 100644 pytests/common_test/test_expression_auto_check_task.py create mode 100644 pytests/test_maisaka_tool_logging.py create mode 100644 pytests/test_mute_plugin_sdk.py create mode 100644 pytests/test_openai_client_toolless_request.py 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 = ( + "
" + "调用 ID" + f"{html.escape(tool_call_id)}" + "
" + ) + + return ( + "
" + "" + f"{html.escape(tool_name)}" + "" + "
" + f"{tool_meta_html}" + f"
{html.escape(json.dumps(tool_arguments, ensure_ascii=False, indent=2, default=str))}
" + "
" + "
" + ) + + @classmethod + def _extract_tool_definition_fields(cls, tool_definition: dict[str, Any]) -> tuple[str, str, Any]: + """提取工具定义中的名称、描述和详情内容。""" + + function_info = tool_definition.get("function") + if isinstance(function_info, dict): + tool_name = str(function_info.get("name") or "").strip() or "unknown" + description = str(function_info.get("description") or "").strip() + detail_payload = function_info + else: + tool_name = str(tool_definition.get("name") or "").strip() or "unknown" + description = str(tool_definition.get("description") or "").strip() + detail_payload = tool_definition + return tool_name, description, detail_payload + + @classmethod + def _build_tool_definition_html(cls, tool_definition: dict[str, Any]) -> str: + """将单个传入工具定义渲染为默认折叠的 HTML 卡片。""" + + tool_name, description, detail_payload = cls._extract_tool_definition_fields(tool_definition) + description_html = "" + if description: + description_html = ( + "
" + "说明" + f"{html.escape(description)}" + "
" + ) + + return ( + "
" + "" + f"{html.escape(tool_name)}" + "" + "
" + f"{description_html}" + f"
{html.escape(json.dumps(detail_payload, ensure_ascii=False, indent=2, default=str))}
" + "
" + "
" + ) + @classmethod def _render_tool_call_panel(cls, tool_call: Any, index: int, parent_index: int) -> Panel: title = Text.assemble( @@ -291,6 +370,20 @@ class PromptCLIVisualizer: return "\n\n" + ("\n\n" + ("=" * 80) + "\n\n").join(sections) if sections else "[空 Prompt]" + @classmethod + def _build_tool_definition_dump_text(cls, tool_definitions: list[dict[str, Any]] | None) -> str: + """构建传入工具定义的文本备份内容。""" + + if not tool_definitions: + return "" + + sections: List[str] = ["[tool_definitions]"] + for index, tool_definition in enumerate(tool_definitions, start=1): + tool_name, _, detail_payload = cls._extract_tool_definition_fields(tool_definition) + sections.append(f"[{index}] name={tool_name}") + sections.append(json.dumps(detail_payload, ensure_ascii=False, indent=2, default=str)) + return "\n\n".join(sections).strip() + @classmethod def _render_message_content_html(cls, content: Any) -> str: if isinstance(content, str): @@ -356,6 +449,7 @@ class PromptCLIVisualizer: *, request_kind: str, selection_reason: str, + tool_definitions: list[dict[str, Any]] | None = None, ) -> str: panel_title, _ = cls.get_request_panel_style(request_kind) message_cards: List[str] = [] @@ -378,16 +472,12 @@ class PromptCLIVisualizer: tool_panels = "" raw_tool_calls = message.get("tool_calls") or [] if isinstance(raw_tool_calls, list) and raw_tool_calls: - tool_items = [] - for tool_call_index, tool_call in enumerate(raw_tool_calls, start=1): - normalized_tool_call = cls.format_tool_call_for_display(tool_call) - tool_items.append( - "
" - f"
工具调用 #{index}.{tool_call_index}
" - f"
{html.escape(json.dumps(normalized_tool_call, ensure_ascii=False, indent=2, default=str))}
" - "
" - ) - tool_panels = "".join(tool_items) + tool_panels = ( + "
" + "
工具调用
" + f"{''.join(cls._build_tool_call_html(tool_call) for tool_call in raw_tool_calls)}" + "
" + ) message_cards.append( "
" @@ -405,6 +495,21 @@ class PromptCLIVisualizer: if selection_reason.strip(): subtitle_html = f"
{html.escape(selection_reason)}
" + tool_definition_section_html = "" + if tool_definitions: + tool_definition_section_html = ( + "
" + "
" + "全部工具" + f"{len(tool_definitions)} 个" + "
" + "
" + "
本次送入模型的工具定义
" + f"{''.join(cls._build_tool_definition_html(tool_definition) for tool_definition in tool_definitions)}" + "
" + "
" + ) + return f""" @@ -491,7 +596,7 @@ class PromptCLIVisualizer: font-weight: 600; }} .message-content pre, - .tool-panel pre {{ + .tool-card pre {{ margin: 0; white-space: pre-wrap; word-break: break-word; @@ -517,18 +622,75 @@ class PromptCLIVisualizer: border-radius: 8px; padding: 3px 8px; }} - .tool-panel {{ + .tool-list {{ + margin-top: 14px; + }} + .tool-list-title {{ + color: #86198f; + font-size: 13px; + font-weight: 800; + margin-bottom: 10px; + }} + .tool-card {{ margin-top: 12px; background: #fcf4ff; border: 1px solid #f0d7fb; border-radius: 14px; - padding: 12px 14px; + overflow: hidden; }} - .tool-panel-title {{ - color: #a21caf; + .tool-card:first-of-type {{ + margin-top: 0; + }} + .tool-card-summary {{ + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + color: #86198f; font-size: 13px; + font-weight: 800; + }} + .tool-card-summary::-webkit-details-marker {{ + display: none; + }} + .tool-card-summary::after {{ + content: "展开"; + color: #a21caf; + font-size: 12px; font-weight: 700; - margin-bottom: 8px; + }} + .tool-card[open] .tool-card-summary::after {{ + content: "收起"; + }} + .tool-card-name {{ + word-break: break-word; + }} + .tool-card-body {{ + border-top: 1px solid #f0d7fb; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.52); + }} + .tool-card-meta {{ + margin-bottom: 10px; + color: #a21caf; + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + }} + .tool-card-meta-label {{ + font-weight: 700; + }} + .tool-card-meta code {{ + background: #faf5ff; + border: 1px solid #e9d5ff; + border-radius: 8px; + padding: 3px 8px; + }} + .tool-card pre {{ + color: #3b0764; }} .image-card {{ background: #f8fafc; @@ -564,6 +726,7 @@ class PromptCLIVisualizer: {subtitle_html} {''.join(message_cards)} + {tool_definition_section_html} """ @@ -578,6 +741,7 @@ class PromptCLIVisualizer: request_kind: str, selection_reason: str, image_display_mode: Literal["legacy", "path_link"], + tool_definitions: list[dict[str, Any]] | None = None, ) -> RenderableType: """构建用于查看完整 prompt 的折叠入口内容。""" @@ -603,10 +767,14 @@ class PromptCLIVisualizer: viewer_messages.append(normalized_message) prompt_dump_text = cls._build_prompt_dump_text(messages) + tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions) + if tool_definition_dump_text: + prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}" viewer_html_text = cls._build_prompt_viewer_html( viewer_messages, request_kind=request_kind, selection_reason=selection_reason, + tool_definitions=tool_definitions, ) saved_paths = PromptPreviewLogger.save_preview_files( chat_id, @@ -623,12 +791,12 @@ class PromptCLIVisualizer: body = Group( Text.from_markup( - f"[bold green]富文本预览:{viewer_html_path}[/bold green] " - f"[link={viewer_uri}]点击在浏览器打开富文本 Prompt 视图[/link]" + f"[bold green]html预览:{viewer_html_path}[/bold green] " + f"[link={viewer_uri}]在浏览器打开 Prompt [/link]" ), Text.from_markup( - f"[magenta]原始文本备份:{prompt_dump_path}[/magenta] " - f"[cyan][link={dump_uri}]点击直接打开 Prompt 文本[/link][/cyan]" + f"[magenta]原始文本:{prompt_dump_path}[/magenta] " + f"[cyan][link={dump_uri}]点击打开 Prompt 文本[/link][/cyan]" ), ) return body @@ -644,6 +812,7 @@ class PromptCLIVisualizer: selection_reason: str, image_display_mode: Literal["legacy", "path_link"], folded: bool, + tool_definitions: list[dict[str, Any]] | None = None, ) -> Panel: """构建用于嵌入结果面板中的 Prompt 区块。""" @@ -656,6 +825,7 @@ class PromptCLIVisualizer: request_kind=request_kind, selection_reason=selection_reason, image_display_mode=image_display_mode, + tool_definitions=tool_definitions, ) else: ordered_panels = cls.build_prompt_panels( diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 139a4607..01e91a4d 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -229,7 +229,7 @@ class MaisakaReasoningEngine: ) -> tuple[Literal["continue", "no_reply", "wait"], Any, list[str]]: """运行 Timing Gate 子代理并返回控制决策。""" - if self._runtime._force_continue_until_reply: + if self._runtime._force_next_timing_continue: return self._build_forced_continue_timing_result() response = await self._run_interruptible_sub_agent( @@ -270,7 +270,7 @@ class MaisakaReasoningEngine: def _build_forced_continue_timing_result(self) -> tuple[Literal["continue"], ChatResponse, list[str]]: """构造跳过 Timing Gate 时使用的伪 continue 结果。""" - reason = self._runtime._build_force_continue_timing_reason() + reason = self._runtime._consume_force_next_timing_continue_reason() or "本轮直接跳过 Timing Gate 并视作 continue。" logger.info(f"{self._runtime.log_prefix} {reason}") return ( "continue", @@ -334,7 +334,10 @@ class MaisakaReasoningEngine: self._runtime._agent_state = self._runtime._STATE_RUNNING if cached_messages: asyncio.create_task(self._runtime._trigger_batch_learning(cached_messages)) - self._append_wait_interrupted_message_if_needed() + if timeout_triggered: + self._runtime._chat_history.append( + self._build_wait_completed_message(has_new_messages=True) + ) await self._ingest_messages(cached_messages) anchor_message = cached_messages[-1] else: @@ -346,7 +349,9 @@ class MaisakaReasoningEngine: continue logger.info(f"{self._runtime.log_prefix} 等待超时后开始新一轮思考") if self._runtime._pending_wait_tool_call_id: - self._runtime._chat_history.append(self._build_wait_timeout_message()) + self._runtime._chat_history.append( + self._build_wait_completed_message(has_new_messages=False) + ) self._trim_chat_history() try: @@ -399,7 +404,7 @@ class MaisakaReasoningEngine: ) timing_gate_required = self._mark_timing_gate_completed(timing_action) if timing_action != "continue": - logger.info( + logger.debug( f"{self._runtime.log_prefix} Timing Gate 结束当前回合: " f"回合={round_index + 1} 动作={timing_action}" ) @@ -475,7 +480,6 @@ class MaisakaReasoningEngine: break asyncio.create_task(self._runtime._trigger_batch_learning(interrupted_messages)) - self._append_wait_interrupted_message_if_needed() await self._ingest_messages(interrupted_messages) anchor_message = interrupted_messages[-1] logger.info( @@ -588,33 +592,22 @@ class MaisakaReasoningEngine: return self._runtime.message_cache[-1] return None - def _build_wait_timeout_message(self) -> ToolResultMessage: - """构造 wait 超时后的工具结果消息。""" + def _build_wait_completed_message(self, *, has_new_messages: bool) -> ToolResultMessage: + """构造 wait 完成后的工具结果消息。""" tool_call_id = self._runtime._pending_wait_tool_call_id or "wait_timeout" self._runtime._pending_wait_tool_call_id = None + content = ( + "等待已结束,期间收到了新的用户输入。请结合这些新消息继续下一轮思考。" + if has_new_messages + else "等待已超时,期间没有收到新的用户输入。请基于现有上下文继续下一轮思考。" + ) return ToolResultMessage( - content="等待已超时,期间没有收到新的用户输入。请基于现有上下文继续下一轮思考。", + content=content, timestamp=datetime.now(), tool_call_id=tool_call_id, tool_name="wait", ) - def _append_wait_interrupted_message_if_needed(self) -> None: - """如果 wait 被新消息打断,则补一条对应的工具结果消息。""" - tool_call_id = self._runtime._pending_wait_tool_call_id - if not tool_call_id: - return - - self._runtime._pending_wait_tool_call_id = None - self._runtime._chat_history.append( - ToolResultMessage( - content="等待过程被新的用户输入打断,已继续处理最新消息。", - timestamp=datetime.now(), - tool_call_id=tool_call_id, - tool_name="wait", - ) - ) - async def _ingest_messages(self, messages: list[SessionMessage]) -> None: """处理传入消息列表,将其转换为历史消息并加入聊天历史缓存。""" for message in messages: diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index 1b490e41..acda15ea 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -93,11 +93,10 @@ class MaisakaHeartFlowChatting: self._max_internal_rounds = MAX_INTERNAL_ROUNDS self._max_context_size = max(1, int(global_config.chat.max_context_size)) self._agent_state: Literal["running", "wait", "stop"] = self._STATE_STOP - self._wait_until: Optional[float] = None self._pending_wait_tool_call_id: Optional[str] = None - self._force_continue_until_reply = False - self._force_continue_trigger_message_id = "" - self._force_continue_trigger_reason = "" + self._force_next_timing_continue = False + self._force_next_timing_message_id = "" + self._force_next_timing_reason = "" self._planner_interrupt_flag: Optional[asyncio.Event] = None self._planner_interrupt_requested = False self._planner_interrupt_consecutive_count = 0 @@ -176,9 +175,6 @@ class MaisakaHeartFlowChatting: self.message_cache.append(message) self._message_received_at_by_id[message.message_id] = received_at self._source_messages_by_id[message.message_id] = message - if self._agent_state == self._STATE_WAIT: - self._cancel_wait_timeout_task() - self._wait_until = None if self._agent_state == self._STATE_RUNNING: self._message_debounce_required = True if self._agent_state == self._STATE_RUNNING and self._planner_interrupt_flag is not None: @@ -249,7 +245,6 @@ class MaisakaHeartFlowChatting: def _record_reply_sent(self) -> None: """在成功发送 reply 后记录本轮消息回复时长。""" - self._clear_force_continue_until_reply() if self._reply_latency_measurement_started_at is None: return @@ -309,26 +304,26 @@ class MaisakaHeartFlowChatting: if not message.is_at and not message.is_mentioned: return - self._arm_force_continue_until_reply( + self._arm_force_next_timing_continue( message, is_at=message.is_at, is_mentioned=message.is_mentioned, ) - def _arm_force_continue_until_reply( + def _arm_force_next_timing_continue( self, message: SessionMessage, *, is_at: bool, is_mentioned: bool, ) -> None: - """在检测到 @ 或提及时,要求后续轮次跳过 Timing Gate 直到成功 reply。""" + """在检测到 @ 或提及时,要求下一次 Timing Gate 直接 continue。""" trigger_reason = "@消息" if is_at else "提及消息" if is_mentioned else "触发消息" - was_armed = self._force_continue_until_reply - self._force_continue_until_reply = True - self._force_continue_trigger_message_id = message.message_id - self._force_continue_trigger_reason = trigger_reason + was_armed = self._force_next_timing_continue + self._force_next_timing_continue = True + self._force_next_timing_message_id = message.message_id + self._force_next_timing_reason = trigger_reason if was_armed: logger.info( @@ -338,34 +333,31 @@ class MaisakaHeartFlowChatting: return logger.info( - f"{self.log_prefix} 检测到{trigger_reason},将跳过 Timing Gate 直到成功发送一条 reply;" + f"{self.log_prefix} 检测到{trigger_reason},下一次 Timing Gate 将直接视作 continue;" f"消息编号={message.message_id}" ) - def _clear_force_continue_until_reply(self) -> None: - """在成功发送 reply 后清理强制 continue 状态。""" + def _consume_force_next_timing_continue_reason(self) -> str | None: + """消费一次性 Timing Gate continue 状态,并返回原因描述。""" - if not self._force_continue_until_reply: - return + if not self._force_next_timing_continue: + return None - logger.info( - f"{self.log_prefix} 已成功发送 reply,恢复 Timing Gate;" - f"触发原因={self._force_continue_trigger_reason or '未知'} " - f"触发消息编号={self._force_continue_trigger_message_id or 'unknown'}" - ) - self._force_continue_until_reply = False - self._force_continue_trigger_message_id = "" - self._force_continue_trigger_reason = "" - - def _build_force_continue_timing_reason(self) -> str: - """返回当前强制跳过 Timing Gate 的原因描述。""" - - trigger_reason = self._force_continue_trigger_reason or "@/提及消息" - trigger_message_id = self._force_continue_trigger_message_id or "unknown" - return ( + trigger_reason = self._force_next_timing_reason or "@/提及消息" + trigger_message_id = self._force_next_timing_message_id or "unknown" + reason = ( f"检测到新的{trigger_reason}(消息编号={trigger_message_id})," - "本轮直接跳过 Timing Gate 并视作 continue,直到成功发送一条 reply。" + "本轮直接跳过 Timing Gate 并视作 continue。" ) + logger.info( + f"{self.log_prefix} 已结束本次强制 continue,恢复 Timing Gate;" + f"触发原因={trigger_reason} " + f"触发消息编号={trigger_message_id}" + ) + self._force_next_timing_continue = False + self._force_next_timing_message_id = "" + self._force_next_timing_reason = "" + return reason def _bind_planner_interrupt_flag(self, interrupt_flag: asyncio.Event) -> None: """绑定当前可打断请求使用的中断标记。""" @@ -453,6 +445,9 @@ class MaisakaHeartFlowChatting: def _schedule_message_turn(self) -> None: """为当前待处理消息安排一次内部 turn。""" + if self._agent_state == self._STATE_WAIT: + return + if not self._has_pending_messages() or self._message_turn_scheduled: return @@ -532,8 +527,9 @@ class MaisakaHeartFlowChatting: def _enter_wait_state(self, seconds: Optional[float] = None, tool_call_id: Optional[str] = None) -> None: """切换到等待状态。""" self._agent_state = self._STATE_WAIT - self._wait_until = None if seconds is None else time.time() + seconds self._pending_wait_tool_call_id = tool_call_id + self._message_turn_scheduled = False + self._cancel_deferred_message_turn_task() self._cancel_wait_timeout_task() if seconds is not None: self._wait_timeout_task = asyncio.create_task( @@ -543,7 +539,6 @@ class MaisakaHeartFlowChatting: def _enter_stop_state(self) -> None: """切换到停止状态。""" self._agent_state = self._STATE_STOP - self._wait_until = None self._pending_wait_tool_call_id = None self._cancel_wait_timeout_task() @@ -568,7 +563,6 @@ class MaisakaHeartFlowChatting: logger.info(f"{self.log_prefix} Maisaka 等待已超时") self._agent_state = self._STATE_RUNNING - self._wait_until = None await self._internal_turn_queue.put("timeout") except asyncio.CancelledError: return diff --git a/src/plugin_runtime/component_query.py b/src/plugin_runtime/component_query.py index c4ded56e..dbd448f5 100644 --- a/src/plugin_runtime/component_query.py +++ b/src/plugin_runtime/component_query.py @@ -672,6 +672,32 @@ class ComponentQueryService: collected_specs[entry.name] = self._build_tool_spec(entry) # type: ignore[arg-type] return collected_specs + @staticmethod + def _build_tool_context_payload(context: Optional[ToolExecutionContext]) -> Dict[str, Any]: + """提取插件工具可复用的会话上下文字段。""" + + if context is None: + return {} + + payload: Dict[str, Any] = {} + stream_id = str(context.stream_id or context.session_id or "").strip() + if stream_id: + payload["stream_id"] = stream_id + payload["chat_id"] = stream_id + + anchor_message = context.metadata.get("anchor_message") + message_info = getattr(anchor_message, "message_info", None) + group_info = getattr(message_info, "group_info", None) + user_info = getattr(message_info, "user_info", None) + + group_id = str(getattr(group_info, "group_id", "") or "").strip() + user_id = str(getattr(user_info, "user_id", "") or "").strip() + if group_id: + payload["group_id"] = group_id + if user_id: + payload["user_id"] = user_id + return payload + @staticmethod def _build_tool_invocation_payload( entry: "ToolEntry", @@ -690,16 +716,27 @@ class ComponentQueryService: """ payload = dict(invocation.arguments) + context_payload = ComponentQueryService._build_tool_context_payload(context) if entry.invoke_method == "plugin.invoke_action": - stream_id = context.stream_id if context is not None else invocation.stream_id + stream_id = str( + context_payload.get("stream_id") + or (context.stream_id if context is not None else invocation.stream_id) + or invocation.stream_id + ).strip() reasoning = context.reasoning if context is not None else invocation.reasoning payload = { **payload, + **{key: value for key, value in context_payload.items() if key not in payload or not payload.get(key)}, "stream_id": stream_id, "chat_id": stream_id, "reasoning": reasoning, "action_data": dict(invocation.arguments), } + return payload + + for key, value in context_payload.items(): + if key not in payload or not payload.get(key): + payload[key] = value return payload @staticmethod