From 2b327a31d32c8685fe30b1642fffee5b5ff3f950 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 7 May 2026 16:48:44 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=90=88=E5=B9=B6no=5Freply?= =?UTF-8?q?=E5=92=8Cwait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/src/routes/config/model.tsx | 27 ++++++++++--- prompts/en-US/maisaka_chat.prompt | 10 ++--- prompts/ja-JP/maisaka_chat.prompt | 10 ++--- prompts/zh-CN/maisaka_timing_gate.prompt | 5 +-- pytests/test_maisaka_timing_gate.py | 7 ++++ scripts/analyze_tool_usage_by_chat.py | 35 +++++++++++++++- src/config/official_configs.py | 2 +- src/maisaka/builtin_tool/__init__.py | 3 -- src/maisaka/builtin_tool/no_reply.py | 2 +- src/maisaka/builtin_tool/wait.py | 51 ------------------------ src/maisaka/chat_loop_service.py | 2 +- src/maisaka/reasoning_engine.py | 13 +++--- src/maisaka/runtime.py | 2 +- src/mcp_module/manager.py | 1 - 14 files changed, 82 insertions(+), 88 deletions(-) delete mode 100644 src/maisaka/builtin_tool/wait.py diff --git a/dashboard/src/routes/config/model.tsx b/dashboard/src/routes/config/model.tsx index 9c03849b..3d09ce61 100644 --- a/dashboard/src/routes/config/model.tsx +++ b/dashboard/src/routes/config/model.tsx @@ -78,6 +78,16 @@ function unwrapModelConfig(data: unknown): Record { return data as Record } +function getAdvancedTaskNames(schema: ConfigSchema | null): Set { + const advancedTaskNames = new Set( + (schema?.fields ?? []) + .filter((field) => field.advanced) + .map((field) => field.name) + ) + advancedTaskNames.add('learner') + return advancedTaskNames +} + // 主导出组件:包装 RestartProvider export function ModelConfigPage() { return ( @@ -174,10 +184,15 @@ function ModelConfigPageContent() { }) // 检查任务配置问题 - const checkTaskConfigIssues = useCallback((taskConf: ModelTaskConfig | null, modelList: ModelInfo[]) => { + const checkTaskConfigIssues = useCallback(( + taskConf: ModelTaskConfig | null, + modelList: ModelInfo[], + schema: ConfigSchema | null = taskConfigSchema + ) => { if (!taskConf) return const modelNameSet = new Set(modelList.map(m => m.name)) + const advancedTaskNames = getAdvancedTaskNames(schema) const invalidRefs: { taskName: string; invalidModels: string[] }[] = [] const emptyTaskList: string[] = [] @@ -186,7 +201,7 @@ function ModelConfigPageContent() { // 检查是否有模型 if (!task.model_list || task.model_list.length === 0) { - if (key !== 'learner') { + if (!advancedTaskNames.has(key)) { emptyTaskList.push(key) } continue @@ -201,7 +216,7 @@ function ModelConfigPageContent() { setInvalidModelRefs(invalidRefs) setEmptyTasks(emptyTaskList) - }, []) + }, [taskConfigSchema]) // 加载配置 const loadConfig = useCallback(async () => { @@ -233,13 +248,15 @@ function ModelConfigPageContent() { resetSnapshots(modelList, taskConf) // 解析 model_task_config 的 schema + let nextTaskConfigSchema: ConfigSchema | null = null if (schemaResult.success && schemaResult.data) { const schema = (schemaResult.data as unknown as Record).schema as ConfigSchema - setTaskConfigSchema(schema.nested?.model_task_config ?? null) + nextTaskConfigSchema = schema.nested?.model_task_config ?? null + setTaskConfigSchema(nextTaskConfigSchema) } // 检查任务配置问题 - checkTaskConfigIssues(taskConf, modelList) + checkTaskConfigIssues(taskConf, modelList, nextTaskConfigSchema) // 初始化上一次的 embedding 模型列表 const embeddingModels = taskConf?.embedding?.model_list || [] diff --git a/prompts/en-US/maisaka_chat.prompt b/prompts/en-US/maisaka_chat.prompt index 2c2afa73..85f9a5ad 100644 --- a/prompts/en-US/maisaka_chat.prompt +++ b/prompts/en-US/maisaka_chat.prompt @@ -13,18 +13,16 @@ You need to first gather information that can help {bot_name} take the next acti You can use these tools: -- wait(seconds) - Temporarily pause the conversation, wait for `seconds`, hand the speaking turn to the user, and wait for the other party's new message. -- no_reply() - When you judge that {bot_name} should not speak right now, end the conversation and do not reply in any way until the other party sends a new message. - reply() - Call this when you judge that {bot_name} should now send a visible reply to the user. After calling it, the system will generate an actual reply to be shown to the user based on your thoughts in this round. - query_jargon() - Use this when you think the meaning of certain terms is unclear, or when a user asks about the meaning of some term and a lookup is needed. - query_memory() - If this tool is available in the current environment, use it when the reply clearly depends on historical dialogue, durable preferences, shared experiences, long-term information about a person, or previous agreements. - Other defined tools may also be used as appropriate. Tool usage rules: -1. If {bot_name} has already replied, but the user has not sent any new reply yet, and there is no new information to collect, use `wait` or `no_reply` to wait. -2. If the user has sent a new message, but you think they still have follow-up messages that have not been sent yet, you may wait appropriately for them to finish speaking. -3. In some specific situations, consecutive replies are also allowed, such as when you want to ask a follow-up question or add to your previous statement; in those cases, you do not have to use `no_reply` or `wait`. -4. You need to control your speaking frequency. If it is a one-on-one chat, you may speak at a relatively even frequency. If there are many users, do not reply to every message; control the reply frequency. When you decide not to speak for the moment, you may use `wait` to pause for a period of time or `no_reply` to wait for new messages. +1. If {bot_name} should send a visible reply now, use `reply`. +2. If the user has sent a new message, but you think they still have follow-up messages that have not been sent yet, do not force a reply; end this round with `finish`. +3. In some specific situations, consecutive replies are also allowed, such as when you want to ask a follow-up question or add to your previous statement. +4. You need to control your speaking frequency. If it is a one-on-one chat, you may speak at a relatively even frequency. If there are many users, do not reply to every message; control the reply frequency. When you decide not to speak for the moment, end this round with `finish`. 5. Do not reply to every message. Do not directly reply to sticker-only messages sent by other users. Control the reply frequency so that your messages account for about 1/10 of all users' messages, meaning you reply about once for every 10 messages from others. 6. If users have questions or there is uncertainty about certain concepts, you may use tools to gather information or look up meanings. You may use multiple tools. diff --git a/prompts/ja-JP/maisaka_chat.prompt b/prompts/ja-JP/maisaka_chat.prompt index fba32e32..54aceb69 100644 --- a/prompts/ja-JP/maisaka_chat.prompt +++ b/prompts/ja-JP/maisaka_chat.prompt @@ -12,18 +12,16 @@ まず {bot_name} が次の行動を取るのに役立つ情報を集め、そのうえで返信方針を示してください。 使用できるツール: -- wait(seconds) - 会話を一時停止し、`seconds` 秒待って発話権をユーザーに渡し、相手の新しい発言を待ちます。 -- no_reply() - {bot_name} が今は発言すべきでないと判断した場合、会話を終了し、相手に新しいメッセージが来るまで一切返信しません。 - reply() - {bot_name} が今ユーザーに対して可視の返信を送るべきだと判断したときに呼び出します。呼び出し後、システムはこのターンの思考に基づいて、実際にユーザーへ表示される返信を生成します。 - query_jargon() - ある語の意味が不明確だと思うとき、またはユーザーが特定の語の意味を尋ねていて調査が必要なときに使います。 - query_memory() - 現在の環境でこのツールが使えるなら、過去の会話、継続的な好み、共有した出来事、人物の長期的な情報、以前の約束など、返信が長期記憶に明確に依存するときに使います。 - その他定義済みのツールも、状況に応じて使用できます。 ツール使用ルール: -1. {bot_name} がすでに返信済みで、ユーザーからまだ新しい返信がなく、新たに集めるべき情報もない場合は `wait` または `no_reply` を使って待ってください。 -2. ユーザーに新しい発言があっても、まだ続きの発言が送られていないだけだと判断するなら、適切に待って話し終えるのを待っても構いません。 -3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときは、`no_reply` や `wait` を使わなくても構いません。 -4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`wait` で一定時間待つか、`no_reply` で新着メッセージを待ってください。 +1. {bot_name} が今ユーザーに見える返信を送るべきなら、`reply` を使ってください。 +2. ユーザーに新しい発言があっても、まだ続きの発言が送られていないだけだと判断するなら、無理に返信せず `finish` でこのラウンドを終えてください。 +3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときです。 +4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`finish` でこのラウンドを終えてください。 5. すべてのメッセージに返信しないでください。他ユーザーが送ったスタンプだけのメッセージには直接返信しないでください。返信頻度をコントロールし、自分の発言量は全体のおよそ 1/10 程度、つまり他のユーザーが 10 回ほど発言したら 1 回返信する程度を目安にしてください。 6. ユーザーの疑問や、ある概念への不確かさがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。 diff --git a/prompts/zh-CN/maisaka_timing_gate.prompt b/prompts/zh-CN/maisaka_timing_gate.prompt index 54da41f4..0202357d 100644 --- a/prompts/zh-CN/maisaka_timing_gate.prompt +++ b/prompts/zh-CN/maisaka_timing_gate.prompt @@ -8,11 +8,10 @@ 在当前场景中,不同的人正在互动({bot_name} 也是一位参与的用户),用户也可能正在连续发送消息或彼此互动。 你的任务不是生成对别人可见的发言,也不是直接使用查询类工具,而是判断当前是否应该: - continue:立刻进入下一轮完整思考、搜集信息、回复与其他工具执行 -- wait:固定再等待一段时间,时间到后再重新判断; -- no_reply:本轮不继续,直接等待新的消息 +- no_reply:本轮不继续发言,等待新的消息;也用于用户可能还没说完、需要先把发言权交还给用户的场景 节奏控制规则: -1. 如果 {bot_name} 已经回复,但用户暂时没有新的回复,且没有新信息需要搜集,使用 wait 或者 no_reply 进行等待。 +1. 如果 {bot_name} 已经回复,但用户暂时没有新的回复,且没有新信息需要搜集,使用 no_reply 进行等待。 2. 如果用户有新发言,但是你评估用户还有后续发言尚未发送,可以适当等待让用户说完。 3. 你需要先评估是用户之间在互动还是和{bot_name}在互动,不要盲目插话,弄错回复对象 4. 你需要评估哪些话是对{bot_name}的发言,哪些是用户之间的交流或者自言自语,根据情况适当发言。 diff --git a/pytests/test_maisaka_timing_gate.py b/pytests/test_maisaka_timing_gate.py index ec7eaf98..399b27dc 100644 --- a/pytests/test_maisaka_timing_gate.py +++ b/pytests/test_maisaka_timing_gate.py @@ -6,6 +6,7 @@ import pytest from src.core.tooling import ToolExecutionResult, ToolInvocation from src.llm_models.payload_content.tool_option import ToolCall +from src.maisaka.builtin_tool import get_timing_tools from src.maisaka.chat_loop_service import ChatResponse, MaisakaChatLoopService from src.maisaka.context_messages import AssistantMessage, TIMING_GATE_INVALID_TOOL_HINT_SOURCE from src.maisaka.reasoning_engine import MaisakaReasoningEngine @@ -32,6 +33,12 @@ def _build_chat_response(tool_calls: list[ToolCall]) -> ChatResponse: ) +def test_timing_gate_tools_only_expose_continue_and_no_reply() -> None: + tool_names = {tool_definition["name"] for tool_definition in get_timing_tools()} + + assert tool_names == {"continue", "no_reply"} + + @pytest.mark.asyncio async def test_timing_gate_invalid_tool_defaults_to_no_reply(monkeypatch: pytest.MonkeyPatch) -> None: runtime = SimpleNamespace( diff --git a/scripts/analyze_tool_usage_by_chat.py b/scripts/analyze_tool_usage_by_chat.py index 2ada9a82..fd1c5416 100644 --- a/scripts/analyze_tool_usage_by_chat.py +++ b/scripts/analyze_tool_usage_by_chat.py @@ -3,12 +3,13 @@ from __future__ import annotations from argparse import ArgumentParser, Namespace from collections import defaultdict from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import DefaultDict import csv import json +import re import sqlite3 import sys @@ -45,6 +46,35 @@ def parse_datetime_filter(value: str | None) -> str | None: raise ValueError(f"无法解析时间: {value!r},请使用 YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS") +def parse_recent_filter(value: str | None) -> str | None: + if value is None: + return None + + normalized_value = value.strip().lower() + if not normalized_value: + return None + + match = re.fullmatch(r"(\d+(?:\.\d+)?)([mhdw])", normalized_value) + if match is None: + raise ValueError(f"无法解析最近时间: {value!r},请使用 30m、24h、7d 或 2w") + + amount = float(match.group(1)) + unit = match.group(2) + if amount <= 0: + raise ValueError(f"最近时间必须大于 0: {value!r}") + + if unit == "m": + delta = timedelta(minutes=amount) + elif unit == "h": + delta = timedelta(hours=amount) + elif unit == "d": + delta = timedelta(days=amount) + else: + delta = timedelta(weeks=amount) + + return (datetime.now() - delta).strftime("%Y-%m-%d %H:%M:%S") + + def connect_readonly(db_path: Path) -> sqlite3.Connection: if not db_path.exists(): raise FileNotFoundError(f"数据库文件不存在: {db_path}") @@ -249,6 +279,7 @@ def print_csv(rows: list[ToolUsageRow]) -> None: def parse_args() -> Namespace: parser = ArgumentParser(description="统计不同 chat_id 的工具使用次数和占比。") parser.add_argument("--db", type=Path, default=DEFAULT_DB_PATH, help=f"数据库路径,默认: {DEFAULT_DB_PATH}") + parser.add_argument("--recent", help="统计最近多久的记录,例如: 30m、24h、7d、2w;如果同时指定 --since,则优先使用 --since") parser.add_argument("--since", help="仅统计此时间之后的记录,格式: YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS") parser.add_argument("--until", help="仅统计此时间之前的记录,格式: YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS") parser.add_argument("--min-chat-total", type=int, default=1, help="只显示工具调用总数不低于该值的 chat_id") @@ -261,7 +292,7 @@ def parse_args() -> Namespace: def main() -> None: args = parse_args() - since = parse_datetime_filter(args.since) + since = parse_datetime_filter(args.since) or parse_recent_filter(args.recent) until = parse_datetime_filter(args.until) min_chat_total = max(1, int(args.min_chat_total)) top_tools = args.top_tools if args.top_tools is None else max(1, int(args.top_tools)) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index c3660be2..0476a4e8 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -435,7 +435,7 @@ class ChatConfig(ConfigBase): "advanced": True, }, ) - """Timing Gate 返回 wait/no_reply 时的最小窗口秒数,0 表示不启用冷却""" + """Timing Gate 返回 no_reply 时的最小窗口秒数,0 表示不启用冷却""" group_chat_prompt: str = Field( default=( diff --git a/src/maisaka/builtin_tool/__init__.py b/src/maisaka/builtin_tool/__init__.py index dadd22e9..b2a589bf 100644 --- a/src/maisaka/builtin_tool/__init__.py +++ b/src/maisaka/builtin_tool/__init__.py @@ -30,8 +30,6 @@ from .tool_search import get_tool_spec as get_tool_search_tool_spec from .tool_search import handle_tool as handle_tool_search_tool from .view_complex_message import get_tool_spec as get_view_complex_message_tool_spec from .view_complex_message import handle_tool as handle_view_complex_message_tool -from .wait import get_tool_spec as get_wait_tool_spec -from .wait import handle_tool as handle_wait_tool BuiltinToolHandler = Callable[[ToolInvocation, Optional[ToolExecutionContext]], Awaitable[ToolExecutionResult]] BuiltinToolRawHandler = Callable[ @@ -70,7 +68,6 @@ def _get_query_memory_tool_spec() -> ToolSpec: BUILTIN_TOOL_ENTRIES: List[BuiltinToolEntry] = [ - BuiltinToolEntry("wait", get_wait_tool_spec, handle_wait_tool, stage="timing"), BuiltinToolEntry("no_reply", get_no_reply_tool_spec, handle_no_reply_tool, stage="timing"), BuiltinToolEntry("continue", get_continue_tool_spec, handle_continue_tool, stage="timing"), BuiltinToolEntry("finish", get_finish_tool_spec, handle_finish_tool, stage="action"), diff --git a/src/maisaka/builtin_tool/no_reply.py b/src/maisaka/builtin_tool/no_reply.py index 70e7d243..21ab1be4 100644 --- a/src/maisaka/builtin_tool/no_reply.py +++ b/src/maisaka/builtin_tool/no_reply.py @@ -12,7 +12,7 @@ def get_tool_spec() -> ToolSpec: return ToolSpec( name="no_reply", - brief_description="本轮不进行回复,等待其他用户的新消息。", + brief_description="本轮不进行回复,等待其他用户的新消息;也用于用户可能还没说完、需要先把发言权交还给用户的场景。", provider_name="maisaka_builtin", provider_type="builtin", ) diff --git a/src/maisaka/builtin_tool/wait.py b/src/maisaka/builtin_tool/wait.py deleted file mode 100644 index 97bb9e61..00000000 --- a/src/maisaka/builtin_tool/wait.py +++ /dev/null @@ -1,51 +0,0 @@ -"""wait 内置工具。""" - -from typing import Optional - -from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec - -from .context import BuiltinToolRuntimeContext - - -def get_tool_spec() -> ToolSpec: - """获取 wait 工具声明。""" - - return ToolSpec( - name="wait", - brief_description="暂停当前对话并固定等待一段时间,期间不因新消息提前恢复。", - detailed_description="参数说明:\n- seconds:integer,必填。等待的秒数。等待期间收到的新消息只会暂存,直到超时后再继续处理。", - parameters_schema={ - "type": "object", - "properties": { - "seconds": { - "type": "integer", - "description": "等待的秒数。", - }, - }, - "required": ["seconds"], - }, - provider_name="maisaka_builtin", - provider_type="builtin", - ) - - -async def handle_tool( - tool_ctx: BuiltinToolRuntimeContext, - invocation: ToolInvocation, - context: Optional[ToolExecutionContext] = None, -) -> ToolExecutionResult: - """执行 wait 内置工具。""" - - del context - seconds = invocation.arguments.get("seconds", 30) - try: - wait_seconds = int(seconds) - except (TypeError, ValueError): - wait_seconds = 30 - wait_seconds = max(0, wait_seconds) - 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} 秒;期间收到的新消息不会提前打断本次等待。", - metadata={"pause_execution": True}, - ) diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 35591f94..c259b9c1 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -40,7 +40,7 @@ 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 -TIMING_GATE_TOOL_NAMES = {"continue", "no_reply", "wait"} +TIMING_GATE_TOOL_NAMES = {"continue", "no_reply"} REQUEST_TYPE_BY_REQUEST_KIND = { "planner": "maisaka_planner", "timing_gate": "maisaka_timing_gate", diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 4cb700c2..2b67407f 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -54,7 +54,7 @@ logger = get_logger("maisaka_reasoning_engine") TIMING_GATE_CONTEXT_DROP_HEAD_RATIO = 0.7 TIMING_GATE_MAX_ATTEMPTS = 3 -TIMING_GATE_TOOL_NAMES = {"continue", "no_reply", "wait"} +TIMING_GATE_TOOL_NAMES = {"continue", "no_reply"} HISTORY_SILENT_TOOL_NAMES = {"finish"} @@ -149,10 +149,9 @@ class MaisakaReasoningEngine: return ( "你是 Maisaka 的 timing gate 子代理,只负责决定当前会话下一步的节奏控制。\n" "你必须且只能调用一个工具,不要输出普通文本答案。\n" - "可用工具只有三个:\n" - "1. wait: 适合暂时等待一段时间,再重新判断是否继续。\n" - "2. no_reply: 适合当前不继续本轮,直接等待新的外部消息。\n" - "3. continue: 适合现在立刻进入下一轮正常思考、回复、查询和其他工具执行。\n" + "可用工具只有两个:\n" + "1. no_reply: 适合当前不继续本轮发言,等待新的外部消息或让用户继续说完。\n" + "2. continue: 适合现在立刻进入下一轮正常思考、回复、查询和其他工具执行。\n" "如果需要真正回复消息、查询信息或使用其他工具,应该调用 continue,让主分支继续执行,而不是在这里完成。\n" "不要连续调用多个工具,也不要输出工具之外的计划。" ) @@ -242,7 +241,7 @@ class MaisakaReasoningEngine: async def _run_timing_gate( self, anchor_message: SessionMessage, - ) -> tuple[Literal["continue", "no_reply", "wait"], Any, list[str], list[dict[str, Any]]]: + ) -> tuple[Literal["continue", "no_reply"], Any, list[str], list[dict[str, Any]]]: """运行 Timing Gate 子代理并返回控制决策。""" if self._runtime._force_next_timing_continue: @@ -388,7 +387,7 @@ class MaisakaReasoningEngine: hint_content = ( "Timing Gate 上一轮选择了非法工具:" f"{normalized_tool_text}。\n" - "Timing Gate 只能调用 continue、wait 或 no_reply 中的一个工具。" + "Timing Gate 只能调用 continue 或 no_reply 中的一个工具。" ) self._runtime._chat_history.append( SessionBackedMessage( diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index 4701d7ce..6bcf587c 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -572,7 +572,7 @@ class MaisakaHeartFlowChatting: return self._force_next_timing_continue async def _wait_for_timing_gate_non_continue_cooldown(self, elapsed_seconds: float) -> None: - """仅对 Timing Gate 的 wait/no_reply 动作应用冷却窗口。""" + """仅对 Timing Gate 的 no_reply 动作应用冷却窗口。""" cooldown_seconds = self._timing_gate_non_continue_cooldown_seconds if cooldown_seconds <= 0: diff --git a/src/mcp_module/manager.py b/src/mcp_module/manager.py index f0d054e7..4c7ec5de 100644 --- a/src/mcp_module/manager.py +++ b/src/mcp_module/manager.py @@ -44,7 +44,6 @@ BUILTIN_TOOL_NAMES = frozenset( { "reply", "no_reply", - "wait", "stop", "create_table", "list_tables",