feat:合并no_reply和wait

This commit is contained in:
SengokuCola
2026-05-07 16:48:44 +08:00
parent f41051f836
commit 2b327a31d3
14 changed files with 82 additions and 88 deletions

View File

@@ -78,6 +78,16 @@ function unwrapModelConfig(data: unknown): Record<string, unknown> {
return data as Record<string, unknown>
}
function getAdvancedTaskNames(schema: ConfigSchema | null): Set<string> {
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<string, unknown>).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 || []

View File

@@ -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.

View File

@@ -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. ユーザーの疑問や、ある概念への不確かさがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。

View File

@@ -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}的发言,哪些是用户之间的交流或者自言自语,根据情况适当发言。

View File

@@ -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(

View File

@@ -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))

View File

@@ -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=(

View File

@@ -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"),

View File

@@ -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",
)

View File

@@ -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- secondsinteger必填。等待的秒数。等待期间收到的新消息只会暂存直到超时后再继续处理。",
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},
)

View File

@@ -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",

View File

@@ -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(

View File

@@ -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:

View File

@@ -44,7 +44,6 @@ BUILTIN_TOOL_NAMES = frozenset(
{
"reply",
"no_reply",
"wait",
"stop",
"create_table",
"list_tables",