From 0852c38e81116ac363859a20bc1b40b4741a92fc Mon Sep 17 00:00:00 2001
From: SengokuCola <1026294844@qq.com>
Date: Thu, 9 Apr 2026 20:29:01 +0800
Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E9=87=87=E7=94=A8tool=E7=B4=A2?=
=?UTF-8?q?=E5=BC=95=E5=B1=95=E5=BC=80=E6=96=B9=E5=BC=8F=E5=8E=8B=E7=BC=A9?=
=?UTF-8?q?tool=EF=BC=8C=E7=A7=BB=E9=99=A4tool=E8=BF=87=E6=BB=A4=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
prompts/zh-CN/maisaka_chat.prompt | 4 +-
src/config/config.py | 3 +-
src/config/official_configs.py | 29 ---
src/maisaka/builtin_tool/__init__.py | 5 +
src/maisaka/builtin_tool/tool_search.py | 106 +++++++++
src/maisaka/chat_loop_service.py | 292 ++----------------------
src/maisaka/reasoning_engine.py | 53 +++--
src/maisaka/runtime.py | 116 +++++++++-
tests/test_maisaka_deferred_tools.py | 63 +++++
tests/test_maisaka_tool_visibility.py | 7 +
10 files changed, 359 insertions(+), 319 deletions(-)
create mode 100644 src/maisaka/builtin_tool/tool_search.py
create mode 100644 tests/test_maisaka_deferred_tools.py
diff --git a/prompts/zh-CN/maisaka_chat.prompt b/prompts/zh-CN/maisaka_chat.prompt
index 957ca528..ddba3952 100644
--- a/prompts/zh-CN/maisaka_chat.prompt
+++ b/prompts/zh-CN/maisaka_chat.prompt
@@ -15,13 +15,15 @@
- reply():当你判断{bot_name}现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。你可以针对某个用户回复,也可以对所有用户回复。
- query_jargon():当你认为某些词的含义不明确,或用户询问某些词的含义,需要进行查询
- query_memory():如果当前可用工具中存在它,当回复明显依赖历史对话、长期偏好、共同经历、人物长期信息或之前约定时使用
+- tool_search():当你在 `...` 中看到 deferred tools 列表,并且需要其中某个工具时,先调用它来搜索并发现对应工具;它只负责让工具在后续轮次变为可用,不直接执行业务
- 其他定义的工具,你可以视情况合适使用
工具使用规则:
1. 你当前处于 Action Loop 阶段,节奏控制由独立的 timing gate 负责;如果系统让你继续,就专注于分析、搜集信息和执行真正需要的工具。
2. 如果存在用户的疑问,或者对某些概念的不确定,你可以使用工具来搜集信息或者查询含义,你可以使用多个工具。
3. 当你判断 {bot_name} 现在应该正式发出可见回复时,调用 reply()。
-4. 如果需要补充上下文、查看消息、查询黑话、检索记忆或使用其他可用工具,可以按需调用。
+4. 如果看到 `` 中列出了 deferred tools,而你需要其中某个工具,先调用 tool_search() 搜索该工具,等它在后续轮次变为可用后再正常调用。
+5. 如果需要补充上下文、查看消息、查询黑话、检索记忆或使用其他当前可用工具,可以按需调用。
长期记忆使用建议:
1. 仅当历史信息会明显影响当前回复时,才考虑调用 `query_memory()`。
diff --git a/src/config/config.py b/src/config/config.py
index 6684f998..64e1ec62 100644
--- a/src/config/config.py
+++ b/src/config/config.py
@@ -23,7 +23,6 @@ from .official_configs import (
EmojiConfig,
ExpressionConfig,
KeywordReactionConfig,
- LPMMKnowledgeConfig,
MaiSakaConfig,
MaimMessageConfig,
MCPConfig,
@@ -55,7 +54,7 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config"
BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0"
-CONFIG_VERSION: str = "8.5.3"
+CONFIG_VERSION: str = "8.5.4"
MODEL_CONFIG_VERSION: str = "1.13.1"
logger = get_logger("config")
diff --git a/src/config/official_configs.py b/src/config/official_configs.py
index b6db112a..e6f56f5d 100644
--- a/src/config/official_configs.py
+++ b/src/config/official_configs.py
@@ -1460,35 +1460,6 @@ class MaiSakaConfig(ConfigBase):
)
"""MaiSaka 使用的用户名称"""
- tool_filter_task_name: str = Field(
- default="utils",
- json_schema_extra={
- "x-widget": "input",
- "x-icon": "sparkles",
- },
- )
- """工具筛选预判使用的模型任务名"""
-
- tool_filter_threshold: int = Field(
- default=20,
- ge=1,
- json_schema_extra={
- "x-widget": "input",
- "x-icon": "filter",
- },
- )
- """当可用工具总数超过该阈值时,先进行一轮工具筛选"""
-
- tool_filter_max_keep: int = Field(
- default=5,
- ge=1,
- json_schema_extra={
- "x-widget": "input",
- "x-icon": "list-filter",
- },
- )
- """工具筛选阶段最多保留的非内置工具数量"""
-
show_image_path: bool = Field(
default=True,
json_schema_extra={
diff --git a/src/maisaka/builtin_tool/__init__.py b/src/maisaka/builtin_tool/__init__.py
index 43067840..256a9a44 100644
--- a/src/maisaka/builtin_tool/__init__.py
+++ b/src/maisaka/builtin_tool/__init__.py
@@ -24,6 +24,8 @@ from .reply import get_tool_spec as get_reply_tool_spec
from .reply import handle_tool as handle_reply_tool
from .send_emoji import get_tool_spec as get_send_emoji_tool_spec
from .send_emoji import handle_tool as handle_send_emoji_tool
+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
@@ -52,6 +54,7 @@ def get_action_tool_specs() -> List[ToolSpec]:
get_query_jargon_tool_spec(),
get_query_memory_tool_spec(enabled=bool(global_config.memory.enable_memory_query_tool)),
get_send_emoji_tool_spec(),
+ get_tool_search_tool_spec(),
]
@@ -73,6 +76,7 @@ def get_all_builtin_tool_specs() -> List[ToolSpec]:
get_query_memory_tool_spec(enabled=True),
get_query_person_info_tool_spec(),
get_send_emoji_tool_spec(),
+ get_tool_search_tool_spec(),
]
@@ -111,6 +115,7 @@ def build_builtin_tool_handlers(tool_ctx: BuiltinToolRuntimeContext) -> Dict[str
),
"wait": lambda invocation, context=None: handle_wait_tool(tool_ctx, invocation, context),
"send_emoji": lambda invocation, context=None: handle_send_emoji_tool(tool_ctx, invocation, context),
+ "tool_search": lambda invocation, context=None: handle_tool_search_tool(tool_ctx, invocation, context),
"view_complex_message": lambda invocation, context=None: handle_view_complex_message_tool(
tool_ctx,
invocation,
diff --git a/src/maisaka/builtin_tool/tool_search.py b/src/maisaka/builtin_tool/tool_search.py
new file mode 100644
index 00000000..46da6532
--- /dev/null
+++ b/src/maisaka/builtin_tool/tool_search.py
@@ -0,0 +1,106 @@
+"""tool_search 内置工具。"""
+
+from typing import Any, Dict, List, Optional
+
+import json
+
+from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec
+
+from .context import BuiltinToolRuntimeContext
+
+
+def get_tool_spec() -> ToolSpec:
+ """获取 tool_search 工具声明。"""
+
+ return ToolSpec(
+ name="tool_search",
+ brief_description="在 deferred tools 列表中按名称或关键词搜索工具,并将命中的工具加入后续轮次的可用工具列表。",
+ detailed_description=(
+ "参数说明:\n"
+ "- query:String,必填。工具名、前缀或关键词。\n"
+ "- limit:Integer,可选。最多返回多少个匹配工具,默认为 5。"
+ ),
+ parameters_schema={
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "要搜索的工具名、前缀或关键词。",
+ },
+ "limit": {
+ "type": "integer",
+ "description": "最多返回多少个匹配工具。",
+ "minimum": 1,
+ },
+ },
+ "required": ["query"],
+ },
+ provider_name="maisaka_builtin",
+ provider_type="builtin",
+ )
+
+
+async def handle_tool(
+ tool_ctx: BuiltinToolRuntimeContext,
+ invocation: ToolInvocation,
+ context: Optional[ToolExecutionContext] = None,
+) -> ToolExecutionResult:
+ """执行 tool_search 内置工具。"""
+
+ del context
+ raw_query = invocation.arguments.get("query")
+ if not isinstance(raw_query, str) or not raw_query.strip():
+ return tool_ctx.build_failure_result(
+ invocation.tool_name,
+ "tool_search 需要提供非空的 `query` 字符串参数。",
+ )
+
+ raw_limit = invocation.arguments.get("limit", 5)
+ try:
+ limit = max(1, int(raw_limit))
+ except (TypeError, ValueError):
+ limit = 5
+
+ matched_tool_specs = tool_ctx.runtime.search_deferred_tool_specs(raw_query, limit=limit)
+ matched_tool_names = [tool_spec.name for tool_spec in matched_tool_specs]
+ newly_discovered_tool_names = tool_ctx.runtime.discover_deferred_tools(matched_tool_names)
+
+ structured_content: Dict[str, Any] = {
+ "query": raw_query.strip(),
+ "matched_tool_names": matched_tool_names,
+ "newly_discovered_tool_names": newly_discovered_tool_names,
+ }
+
+ if not matched_tool_names:
+ return tool_ctx.build_success_result(
+ invocation.tool_name,
+ "未找到匹配的 deferred tools,请尝试更完整的工具名、前缀或其他关键词。",
+ structured_content=structured_content,
+ metadata={"record_display_prompt": "tool_search 未找到匹配工具。"},
+ )
+
+ content_lines: List[str] = [
+ f"已找到 {len(matched_tool_names)} 个 deferred tools,它们会在后续轮次中加入可用工具列表:",
+ *[f"- {tool_name}" for tool_name in matched_tool_names],
+ ]
+ if newly_discovered_tool_names:
+ content_lines.extend(
+ [
+ "",
+ "本次新发现的工具:",
+ *[f"- {tool_name}" for tool_name in newly_discovered_tool_names],
+ ]
+ )
+ else:
+ content_lines.extend(["", "这些工具此前已经发现过,无需重复展开。"])
+
+ return tool_ctx.build_success_result(
+ invocation.tool_name,
+ "\n".join(content_lines),
+ structured_content=structured_content,
+ metadata={
+ "matched_tool_names": matched_tool_names,
+ "newly_discovered_tool_names": newly_discovered_tool_names,
+ "record_display_prompt": json.dumps(structured_content, ensure_ascii=False),
+ },
+ )
diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py
index 77e49f2f..c49da004 100644
--- a/src/maisaka/chat_loop_service.py
+++ b/src/maisaka/chat_loop_service.py
@@ -5,20 +5,18 @@ from datetime import datetime
from typing import Any, List, Optional, Sequence
import asyncio
-import json
import random
-from pydantic import BaseModel, Field as PydanticField
from rich.console import RenderableType
from src.common.data_models.llm_service_data_models import LLMGenerationOptions
from src.common.logger import get_logger
from src.common.prompt_i18n import load_prompt
from src.common.utils.utils_session import SessionUtils
from src.config.config import global_config
-from src.core.tooling import ToolRegistry, ToolSpec
+from src.core.tooling import ToolRegistry
from src.llm_models.model_client.base_client import BaseClient
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType
-from src.llm_models.payload_content.resp_format import RespFormat, RespFormatType
+from src.llm_models.payload_content.resp_format import RespFormat
from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, ToolOption, normalize_tool_options
from src.plugin_runtime.hook_payloads import (
deserialize_prompt_messages,
@@ -54,13 +52,6 @@ class ChatResponse:
prompt_section: Optional[RenderableType] = None
-class ToolFilterSelection(BaseModel):
- """工具筛选响应。"""
-
- selected_tool_names: list[str] = PydanticField(default_factory=list)
- """经过预筛后保留的候选工具名称列表。"""
-
-
logger = get_logger("maisaka_chat_loop")
@@ -217,10 +208,6 @@ class MaisakaChatLoopService:
else:
self._chat_system_prompt = chat_system_prompt
self._llm_chat = LLMServiceClient(task_name="planner", request_type="maisaka_planner")
- self._tool_filter_llm = LLMServiceClient(
- task_name=global_config.maisaka.tool_filter_task_name,
- request_type="maisaka_tool_filter",
- )
@property
def personality_prompt(self) -> str:
@@ -399,6 +386,7 @@ class MaisakaChatLoopService:
self,
selected_history: List[LLMContextMessage],
*,
+ injected_user_messages: Sequence[str] | None = None,
system_prompt: Optional[str] = None,
) -> List[Message]:
"""构造发给大模型的消息列表。
@@ -420,61 +408,19 @@ class MaisakaChatLoopService:
if llm_message is not None:
messages.append(llm_message)
+ for injected_message in injected_user_messages or []:
+ normalized_message = str(injected_message or "").strip()
+ if not normalized_message:
+ continue
+ messages.append(
+ MessageBuilder()
+ .set_role(RoleType.User)
+ .add_text_content(normalized_message)
+ .build()
+ )
+
return messages
- @staticmethod
- def _is_builtin_tool_spec(tool_spec: ToolSpec) -> bool:
- """判断一个工具是否属于默认内置工具。
-
- Args:
- tool_spec: 待判断的工具声明。
-
- Returns:
- bool: 是否为默认内置工具。
- """
-
- return tool_spec.provider_type == "builtin" or tool_spec.provider_name == "maisaka_builtin"
-
- @classmethod
- def _split_builtin_and_candidate_tools(
- cls,
- tool_specs: List[ToolSpec],
- ) -> tuple[List[ToolSpec], List[ToolSpec]]:
- """拆分内置工具与可筛选工具列表。
-
- Args:
- tool_specs: 当前全部工具声明。
-
- Returns:
- tuple[List[ToolSpec], List[ToolSpec]]: `(内置工具, 可筛选工具)`。
- """
-
- builtin_tool_specs: List[ToolSpec] = []
- candidate_tool_specs: List[ToolSpec] = []
- for tool_spec in tool_specs:
- if cls._is_builtin_tool_spec(tool_spec):
- builtin_tool_specs.append(tool_spec)
- else:
- candidate_tool_specs.append(tool_spec)
- return builtin_tool_specs, candidate_tool_specs
-
- @staticmethod
- def _truncate_tool_filter_text(text: str, max_length: int = 180) -> str:
- """截断工具筛选阶段展示的文本。
-
- Args:
- text: 原始文本。
- max_length: 最长保留字符数。
-
- Returns:
- str: 截断后的文本。
- """
-
- normalized_text = text.strip()
- if len(normalized_text) <= max_length:
- return normalized_text
- return f"{normalized_text[: max_length - 1]}…"
-
@staticmethod
def _build_tool_names_log_text(tool_definitions: Sequence[ToolDefinitionInput]) -> str:
"""构造 planner 请求前的工具列表日志文本。
@@ -503,211 +449,11 @@ class MaisakaChatLoopService:
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],
- candidate_tool_specs: List[ToolSpec],
- max_keep: int,
- ) -> str:
- """构造小模型工具预筛选提示词。
-
- Args:
- selected_history: 已选中的对话上下文。
- candidate_tool_specs: 非内置候选工具列表。
- max_keep: 最多保留的候选工具数量。
-
- Returns:
- str: 用于工具预筛的小模型提示词。
- """
-
- history_lines: List[str] = []
- for message in selected_history[-10:]:
- plain_text = message.processed_plain_text.strip()
- if not plain_text:
- continue
- history_lines.append(
- f"- {message.role}: {self._truncate_tool_filter_text(plain_text, max_length=200)}"
- )
-
- if history_lines:
- history_section = "\n".join(history_lines)
- else:
- history_section = "- 当前没有可用的对话上下文。"
-
- tool_lines = [
- f"- {tool_spec.name}: {tool_spec.brief_description.strip() or '无简要描述'}"
- for tool_spec in candidate_tool_specs
- ]
- tool_section = "\n".join(tool_lines) if tool_lines else "- 当前没有候选工具。"
-
- return (
- "你是 Maisaka 的工具预筛选器。\n"
- "你的任务是在正式进入 planner 前,根据当前情景从候选工具中挑出最可能马上会用到的工具。\n"
- "默认内置工具已经自动保留,不在候选列表中,你不需要再次选择它们。\n"
- "你只能参考工具的简要描述,不要假设未描述的隐藏能力。\n"
- f"最多保留 {max_keep} 个候选工具;如果都不合适,可以返回空数组。\n"
- "请严格返回 JSON 对象,格式为:"
- '{"selected_tool_names":["工具名1","工具名2"]}\n\n'
- f"【最近对话】\n{history_section}\n\n"
- f"【候选工具(仅简要描述)】\n{tool_section}"
- )
-
- @staticmethod
- def _parse_tool_filter_response(
- response_text: str,
- candidate_tool_specs: List[ToolSpec],
- max_keep: int,
- ) -> List[ToolSpec] | None:
- """解析工具预筛选响应。
-
- Args:
- response_text: 小模型返回的原始文本。
- candidate_tool_specs: 非内置候选工具列表。
- max_keep: 最多保留的候选工具数量。
-
- Returns:
- List[ToolSpec] | None: 成功解析时返回筛选后的工具列表;解析失败时返回 ``None``。
- """
-
- normalized_response = response_text.strip()
- if not normalized_response:
- return None
-
- selected_tool_names: List[str]
- try:
- selected_tool_names = ToolFilterSelection.model_validate_json(normalized_response).selected_tool_names
- except Exception:
- try:
- parsed_payload = json.loads(normalized_response)
- except json.JSONDecodeError:
- return None
-
- if isinstance(parsed_payload, dict):
- raw_tool_names = parsed_payload.get("selected_tool_names", [])
- elif isinstance(parsed_payload, list):
- raw_tool_names = parsed_payload
- else:
- return None
-
- if not isinstance(raw_tool_names, list):
- return None
-
- selected_tool_names = []
- for item in raw_tool_names:
- normalized_name = str(item).strip()
- if normalized_name:
- selected_tool_names.append(normalized_name)
-
- candidate_map = {tool_spec.name: tool_spec for tool_spec in candidate_tool_specs}
- filtered_tool_specs: List[ToolSpec] = []
- seen_names: set[str] = set()
- for tool_name in selected_tool_names:
- normalized_name = tool_name.strip()
- if not normalized_name or normalized_name in seen_names:
- continue
- tool_spec = candidate_map.get(normalized_name)
- if tool_spec is None:
- continue
-
- seen_names.add(normalized_name)
- filtered_tool_specs.append(tool_spec)
- if len(filtered_tool_specs) >= max_keep:
- break
-
- return filtered_tool_specs
-
- async def _filter_tool_specs_for_planner(
- self,
- selected_history: List[LLMContextMessage],
- tool_specs: List[ToolSpec],
- ) -> List[ToolSpec]:
- """在将工具交给 planner 前进行快速预筛选。
-
- Args:
- selected_history: 已选中的对话上下文。
- tool_specs: 当前全部可用工具声明。
-
- Returns:
- List[ToolSpec]: 最终交给 planner 的工具声明列表。
- """
-
- threshold = max(1, int(global_config.maisaka.tool_filter_threshold))
- max_keep = max(1, int(global_config.maisaka.tool_filter_max_keep))
- if len(tool_specs) <= threshold:
- return tool_specs
-
- builtin_tool_specs, candidate_tool_specs = self._split_builtin_and_candidate_tools(tool_specs)
- if not candidate_tool_specs:
- return tool_specs
- if len(candidate_tool_specs) <= max_keep:
- return [*builtin_tool_specs, *candidate_tool_specs]
-
- filter_prompt = self._build_tool_filter_prompt(selected_history, candidate_tool_specs, max_keep)
- logger.info(
- "工具预筛选开始: "
- f"总工具数={len(tool_specs)} "
- f"内置工具数={len(builtin_tool_specs)} "
- f"候选工具数={len(candidate_tool_specs)} "
- f"最多保留候选数={max_keep} "
- f"过滤前全部工具名={self._build_tool_spec_names_log_text(tool_specs)}"
- )
-
- try:
- generation_result = await self._tool_filter_llm.generate_response(
- prompt=filter_prompt,
- options=LLMGenerationOptions(
- temperature=0.0,
- max_tokens=256,
- response_format=RespFormat(
- format_type=RespFormatType.JSON_SCHEMA,
- schema=ToolFilterSelection,
- ),
- ),
- )
- except Exception as exc:
- logger.warning(f"工具预筛选失败,保留全部工具。错误={exc}")
- return tool_specs
-
- filtered_candidate_tool_specs = self._parse_tool_filter_response(
- generation_result.response or "",
- candidate_tool_specs,
- max_keep,
- )
- if filtered_candidate_tool_specs is None:
- logger.warning(
- "工具预筛选返回结果无法解析,保留全部工具。"
- f" 原始返回={generation_result.response or ''!r}"
- )
- return tool_specs
-
- filtered_tool_specs = [*builtin_tool_specs, *filtered_candidate_tool_specs]
- if not filtered_tool_specs:
- logger.warning("工具预筛选得到空结果,保留全部工具以避免主流程失去工具能力。")
- return tool_specs
-
- logger.info(
- "工具预筛选完成: "
- 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
-
async def chat_loop_step(
self,
chat_history: List[LLMContextMessage],
*,
+ injected_user_messages: Sequence[str] | None = None,
request_kind: str = "planner",
response_format: RespFormat | None = None,
tool_definitions: Sequence[ToolDefinitionInput] | None = None,
@@ -724,7 +470,10 @@ class MaisakaChatLoopService:
if not self._prompts_loaded:
await self.ensure_chat_prompt_loaded()
selected_history, selection_reason = self.select_llm_context_messages(chat_history)
- built_messages = self._build_request_messages(selected_history)
+ built_messages = self._build_request_messages(
+ selected_history,
+ injected_user_messages=injected_user_messages,
+ )
def message_factory(_client: BaseClient) -> List[Message]:
"""返回当前轮次已经构建好的请求消息。
@@ -744,8 +493,7 @@ class MaisakaChatLoopService:
all_tools = list(tool_definitions)
elif self._tool_registry is not None:
tool_specs = await self._tool_registry.list_tools()
- filtered_tool_specs = await self._filter_tool_specs_for_planner(selected_history, tool_specs)
- all_tools = [tool_spec.to_llm_definition() for tool_spec in filtered_tool_specs]
+ all_tools = [tool_spec.to_llm_definition() for tool_spec in tool_specs]
else:
all_tools = [*get_builtin_tools(), *self._extra_tools]
diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py
index 01e91a4d..00b0604b 100644
--- a/src/maisaka/reasoning_engine.py
+++ b/src/maisaka/reasoning_engine.py
@@ -93,6 +93,7 @@ class MaisakaReasoningEngine:
async def _run_interruptible_planner(
self,
*,
+ injected_user_messages: Optional[list[str]] = None,
tool_definitions: Optional[list[dict[str, Any]]] = None,
) -> Any:
"""运行一轮可被新消息打断的主 planner 请求。"""
@@ -104,6 +105,7 @@ class MaisakaReasoningEngine:
try:
return await self._runtime._chat_loop_service.chat_loop_step(
self._runtime._chat_history,
+ injected_user_messages=injected_user_messages,
tool_definitions=tool_definitions,
)
except ReqAbortException:
@@ -173,22 +175,34 @@ class MaisakaReasoningEngine:
except Exception:
return self._build_timing_gate_fallback_prompt()
- async def _build_action_tool_definitions(self) -> list[dict[str, Any]]:
- """构造 Action Loop 阶段可见的工具定义。"""
+ async def _build_action_tool_definitions(self) -> tuple[list[dict[str, Any]], str]:
+ """构造 Action Loop 阶段可见的工具定义与 deferred tools 提示。"""
if self._runtime._tool_registry is None:
- return []
+ self._runtime.update_deferred_tool_specs([])
+ self._runtime.set_current_action_tool_names([])
+ return [], ""
tool_specs = await self._runtime._tool_registry.list_tools()
- return [
- tool_spec.to_llm_definition()
- for tool_spec in tool_specs
- if tool_spec.name not in ACTION_HIDDEN_TOOL_NAMES
- and (
- tool_spec.provider_name != "maisaka_builtin"
- or tool_spec.name in ACTION_BUILTIN_TOOL_NAMES
- )
- ]
+ visible_builtin_tool_specs: list[ToolSpec] = []
+ deferred_tool_specs: list[ToolSpec] = []
+ for tool_spec in tool_specs:
+ if tool_spec.name in ACTION_HIDDEN_TOOL_NAMES:
+ continue
+ if tool_spec.provider_name == "maisaka_builtin":
+ if tool_spec.name in ACTION_BUILTIN_TOOL_NAMES:
+ visible_builtin_tool_specs.append(tool_spec)
+ continue
+ deferred_tool_specs.append(tool_spec)
+
+ self._runtime.update_deferred_tool_specs(deferred_tool_specs)
+ discovered_deferred_tool_specs = self._runtime.get_discovered_deferred_tool_specs()
+ visible_tool_specs = [*visible_builtin_tool_specs, *discovered_deferred_tool_specs]
+ self._runtime.set_current_action_tool_names([tool_spec.name for tool_spec in visible_tool_specs])
+ return (
+ [tool_spec.to_llm_definition() for tool_spec in visible_tool_specs],
+ self._runtime.build_deferred_tools_reminder(),
+ )
async def _invoke_tool_call(
self,
@@ -416,7 +430,7 @@ class MaisakaReasoningEngine:
)
planner_started_at = time.time()
- action_tool_definitions = await self._build_action_tool_definitions()
+ action_tool_definitions, deferred_tools_reminder = await self._build_action_tool_definitions()
logger.info(
f"{self._runtime.log_prefix} 规划器开始执行: "
f"回合={round_index + 1} "
@@ -424,6 +438,7 @@ class MaisakaReasoningEngine:
f"开始时间={planner_started_at:.3f}"
)
response = await self._run_interruptible_planner(
+ injected_user_messages=[deferred_tools_reminder] if deferred_tools_reminder else None,
tool_definitions=action_tool_definitions,
)
planner_duration_ms = (time.time() - planner_started_at) * 1000
@@ -1175,7 +1190,17 @@ class MaisakaReasoningEngine:
for tool_call in tool_calls:
invocation = self._build_tool_invocation(tool_call, latest_thought)
tool_started_at = time.time()
- result = await self._runtime._tool_registry.invoke(invocation, execution_context)
+ if not self._runtime.is_action_tool_currently_available(invocation.tool_name):
+ result = ToolExecutionResult(
+ tool_name=invocation.tool_name,
+ success=False,
+ error_message=(
+ f"工具 {invocation.tool_name} 当前未直接暴露给 planner。"
+ "如果它在 deferred tools 提示中,请先调用 tool_search。"
+ ),
+ )
+ else:
+ result = await self._runtime._tool_registry.invoke(invocation, execution_context)
tool_duration_ms = (time.time() - tool_started_at) * 1000
await self._store_tool_execution_record(
invocation,
diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py
index acda15ea..a3dff283 100644
--- a/src/maisaka/runtime.py
+++ b/src/maisaka/runtime.py
@@ -21,7 +21,7 @@ from src.common.data_models.mai_message_data_model import GroupInfo, UserInfo
from src.common.logger import get_logger
from src.common.utils.utils_config import ChatConfigUtils, ExpressionConfigUtils
from src.config.config import global_config
-from src.core.tooling import ToolRegistry
+from src.core.tooling import ToolRegistry, ToolSpec
from src.learners.expression_learner import ExpressionLearner
from src.learners.jargon_miner import JargonMiner
from src.llm_models.payload_content.resp_format import RespFormat
@@ -100,6 +100,9 @@ class MaisakaHeartFlowChatting:
self._planner_interrupt_flag: Optional[asyncio.Event] = None
self._planner_interrupt_requested = False
self._planner_interrupt_consecutive_count = 0
+ self._current_action_tool_names: set[str] = set()
+ self.discovered_tool_names: set[str] = set()
+ self.deferred_tool_specs_by_name: dict[str, ToolSpec] = {}
self._planner_interrupt_max_consecutive_count = max(
0,
int(global_config.chat.planner_interrupt_max_consecutive_count),
@@ -440,6 +443,117 @@ class MaisakaHeartFlowChatting:
tool_definitions=[] if tool_definitions is None else tool_definitions,
)
+ def set_current_action_tool_names(self, tool_names: Sequence[str]) -> None:
+ """记录当前 Action Loop 已实际暴露给 planner 的工具名集合。"""
+
+ self._current_action_tool_names = {tool_name for tool_name in tool_names if str(tool_name).strip()}
+
+ def is_action_tool_currently_available(self, tool_name: str) -> bool:
+ """判断指定工具在当前 Action Loop 轮次中是否真实可用。"""
+
+ normalized_name = str(tool_name).strip()
+ return bool(normalized_name) and normalized_name in self._current_action_tool_names
+
+ def update_deferred_tool_specs(self, deferred_tool_specs: Sequence[ToolSpec]) -> None:
+ """刷新当前会话的 deferred tools 池,并清理失效的已发现工具。"""
+
+ next_specs_by_name: dict[str, ToolSpec] = {}
+ for tool_spec in deferred_tool_specs:
+ normalized_name = tool_spec.name.strip()
+ if not normalized_name:
+ continue
+ next_specs_by_name[normalized_name] = tool_spec
+
+ self.deferred_tool_specs_by_name = next_specs_by_name
+ self.discovered_tool_names.intersection_update(next_specs_by_name.keys())
+
+ def get_discovered_deferred_tool_specs(self) -> list[ToolSpec]:
+ """返回当前会话中已发现、且仍然有效的 deferred tools。"""
+
+ return [
+ tool_spec
+ for tool_name, tool_spec in self.deferred_tool_specs_by_name.items()
+ if tool_name in self.discovered_tool_names
+ ]
+
+ def build_deferred_tools_reminder(self) -> str:
+ """构造供 planner 使用的 deferred tools 提示消息。"""
+
+ undiscovered_tool_names = [
+ tool_name
+ for tool_name in self.deferred_tool_specs_by_name
+ if tool_name not in self.discovered_tool_names
+ ]
+ if not undiscovered_tool_names:
+ return ""
+
+ tool_lines = [f"{index}. {tool_name}" for index, tool_name in enumerate(undiscovered_tool_names, start=1)]
+ reminder_lines = [
+ "",
+ "以下工具当前未直接暴露给你,但可以通过 tool_search 工具发现并在后续轮次中使用:",
+ *tool_lines,
+ "",
+ "如需其中某个工具,请先调用 tool_search。tool_search 只负责发现工具,不直接执行业务。",
+ "",
+ ]
+ return "\n".join(reminder_lines)
+
+ def search_deferred_tool_specs(
+ self,
+ query: str,
+ *,
+ limit: int,
+ ) -> list[ToolSpec]:
+ """按名称或简要描述搜索 deferred tools。"""
+
+ normalized_query = " ".join(query.lower().split()).strip()
+ if not normalized_query:
+ return []
+
+ scored_matches: list[tuple[int, str, ToolSpec]] = []
+ query_terms = [term for term in normalized_query.replace("_", " ").replace("-", " ").split() if term]
+ for tool_name, tool_spec in self.deferred_tool_specs_by_name.items():
+ lower_name = tool_name.lower()
+ lower_description = tool_spec.brief_description.lower()
+ score = 0
+
+ if normalized_query == lower_name:
+ score += 1000
+ if lower_name.startswith(normalized_query):
+ score += 300
+ if normalized_query in lower_name:
+ score += 200
+ if normalized_query in lower_description:
+ score += 100
+
+ for query_term in query_terms:
+ if query_term in lower_name:
+ score += 25
+ if query_term in lower_description:
+ score += 10
+
+ if score <= 0:
+ continue
+
+ scored_matches.append((score, tool_name, tool_spec))
+
+ scored_matches.sort(key=lambda item: (-item[0], item[1]))
+ return [tool_spec for _, _, tool_spec in scored_matches[: max(1, limit)]]
+
+ def discover_deferred_tools(self, tool_names: Sequence[str]) -> list[str]:
+ """将指定 deferred tools 标记为已发现,并返回本次新发现的工具名。"""
+
+ newly_discovered_tool_names: list[str] = []
+ for raw_tool_name in tool_names:
+ normalized_name = str(raw_tool_name).strip()
+ if not normalized_name or normalized_name not in self.deferred_tool_specs_by_name:
+ continue
+ if normalized_name in self.discovered_tool_names:
+ continue
+ self.discovered_tool_names.add(normalized_name)
+ newly_discovered_tool_names.append(normalized_name)
+ return newly_discovered_tool_names
+
def _has_pending_messages(self) -> bool:
return self._last_processed_index < len(self.message_cache)
diff --git a/tests/test_maisaka_deferred_tools.py b/tests/test_maisaka_deferred_tools.py
new file mode 100644
index 00000000..ba03e928
--- /dev/null
+++ b/tests/test_maisaka_deferred_tools.py
@@ -0,0 +1,63 @@
+from src.core.tooling import ToolSpec
+from src.llm_models.payload_content.message import RoleType
+from src.maisaka.chat_loop_service import MaisakaChatLoopService
+from src.maisaka.runtime import MaisakaHeartFlowChatting
+
+
+def _build_runtime_stub() -> MaisakaHeartFlowChatting:
+ runtime = object.__new__(MaisakaHeartFlowChatting)
+ runtime._current_action_tool_names = set()
+ runtime.deferred_tool_specs_by_name = {}
+ runtime.discovered_tool_names = set()
+ return runtime
+
+
+def test_deferred_tools_reminder_only_lists_undiscovered_tools() -> None:
+ runtime = _build_runtime_stub()
+ runtime.update_deferred_tool_specs(
+ [
+ ToolSpec(name="plugin_alpha", brief_description="alpha"),
+ ToolSpec(name="plugin_beta", brief_description="beta"),
+ ]
+ )
+ runtime.discover_deferred_tools(["plugin_alpha"])
+
+ reminder = runtime.build_deferred_tools_reminder()
+
+ assert "plugin_alpha" not in reminder
+ assert "1. plugin_beta" in reminder
+ assert "" in reminder
+ assert "tool_search" in reminder
+
+
+def test_search_and_discover_deferred_tools() -> None:
+ runtime = _build_runtime_stub()
+ runtime.update_deferred_tool_specs(
+ [
+ ToolSpec(name="mcp__slack__send_message", brief_description="向 Slack 发送消息"),
+ ToolSpec(name="mcp__github__create_issue", brief_description="在 GitHub 创建 Issue"),
+ ]
+ )
+
+ matched_tool_specs = runtime.search_deferred_tool_specs("slack send", limit=5)
+ newly_discovered_tool_names = runtime.discover_deferred_tools([tool_spec.name for tool_spec in matched_tool_specs])
+
+ assert [tool_spec.name for tool_spec in matched_tool_specs] == ["mcp__slack__send_message"]
+ assert newly_discovered_tool_names == ["mcp__slack__send_message"]
+ assert [tool_spec.name for tool_spec in runtime.get_discovered_deferred_tool_specs()] == [
+ "mcp__slack__send_message"
+ ]
+
+
+def test_build_request_messages_appends_injected_user_message() -> None:
+ chat_loop_service = MaisakaChatLoopService(chat_system_prompt="system prompt")
+
+ messages = chat_loop_service._build_request_messages(
+ [],
+ injected_user_messages=["\n1. plugin_beta\n"],
+ )
+
+ assert len(messages) == 2
+ assert messages[0].role == RoleType.System
+ assert messages[1].role == RoleType.User
+ assert messages[1].content == "\n1. plugin_beta\n"
diff --git a/tests/test_maisaka_tool_visibility.py b/tests/test_maisaka_tool_visibility.py
index acdbcc79..4f241070 100644
--- a/tests/test_maisaka_tool_visibility.py
+++ b/tests/test_maisaka_tool_visibility.py
@@ -12,3 +12,10 @@ def test_wait_tool_not_available_in_action_stage() -> None:
assert "wait" not in tool_names
assert "finish" in tool_names
+ assert "tool_search" in tool_names
+
+
+def test_tool_search_not_available_in_timing_stage() -> None:
+ tool_names = {tool_spec.name for tool_spec in get_timing_tool_specs()}
+
+ assert "tool_search" not in tool_names