feat:采用tool索引展开方式压缩tool,移除tool过滤器

This commit is contained in:
SengokuCola
2026-04-09 20:29:01 +08:00
parent 243b8deb43
commit 0852c38e81
10 changed files with 359 additions and 319 deletions

View File

@@ -15,13 +15,15 @@
- reply():当你判断{bot_name}现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。你可以针对某个用户回复,也可以对所有用户回复。 - reply():当你判断{bot_name}现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。你可以针对某个用户回复,也可以对所有用户回复。
- query_jargon():当你认为某些词的含义不明确,或用户询问某些词的含义,需要进行查询 - query_jargon():当你认为某些词的含义不明确,或用户询问某些词的含义,需要进行查询
- query_memory():如果当前可用工具中存在它,当回复明显依赖历史对话、长期偏好、共同经历、人物长期信息或之前约定时使用 - query_memory():如果当前可用工具中存在它,当回复明显依赖历史对话、长期偏好、共同经历、人物长期信息或之前约定时使用
- tool_search():当你在 `<system-reminder>...</system-reminder>` 中看到 deferred tools 列表,并且需要其中某个工具时,先调用它来搜索并发现对应工具;它只负责让工具在后续轮次变为可用,不直接执行业务
- 其他定义的工具,你可以视情况合适使用 - 其他定义的工具,你可以视情况合适使用
工具使用规则: 工具使用规则:
1. 你当前处于 Action Loop 阶段,节奏控制由独立的 timing gate 负责;如果系统让你继续,就专注于分析、搜集信息和执行真正需要的工具。 1. 你当前处于 Action Loop 阶段,节奏控制由独立的 timing gate 负责;如果系统让你继续,就专注于分析、搜集信息和执行真正需要的工具。
2. 如果存在用户的疑问,或者对某些概念的不确定,你可以使用工具来搜集信息或者查询含义,你可以使用多个工具。 2. 如果存在用户的疑问,或者对某些概念的不确定,你可以使用工具来搜集信息或者查询含义,你可以使用多个工具。
3. 当你判断 {bot_name} 现在应该正式发出可见回复时,调用 reply()。 3. 当你判断 {bot_name} 现在应该正式发出可见回复时,调用 reply()。
4. 如果需要补充上下文、查看消息、查询黑话、检索记忆或使用其他可用工具,可以按需调用。 4. 如果看到 `<system-reminder>` 中列出了 deferred tools而你需要其中某个工具先调用 tool_search() 搜索该工具,等它在后续轮次变为可用后再正常调用。
5. 如果需要补充上下文、查看消息、查询黑话、检索记忆或使用其他当前可用工具,可以按需调用。
长期记忆使用建议: 长期记忆使用建议:
1. 仅当历史信息会明显影响当前回复时,才考虑调用 `query_memory()`。 1. 仅当历史信息会明显影响当前回复时,才考虑调用 `query_memory()`。

View File

@@ -23,7 +23,6 @@ from .official_configs import (
EmojiConfig, EmojiConfig,
ExpressionConfig, ExpressionConfig,
KeywordReactionConfig, KeywordReactionConfig,
LPMMKnowledgeConfig,
MaiSakaConfig, MaiSakaConfig,
MaimMessageConfig, MaimMessageConfig,
MCPConfig, MCPConfig,
@@ -55,7 +54,7 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config"
BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute() BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0" 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" MODEL_CONFIG_VERSION: str = "1.13.1"
logger = get_logger("config") logger = get_logger("config")

View File

@@ -1460,35 +1460,6 @@ class MaiSakaConfig(ConfigBase):
) )
"""MaiSaka 使用的用户名称""" """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( show_image_path: bool = Field(
default=True, default=True,
json_schema_extra={ json_schema_extra={

View File

@@ -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 .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 get_tool_spec as get_send_emoji_tool_spec
from .send_emoji import handle_tool as handle_send_emoji_tool 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 get_tool_spec as get_view_complex_message_tool_spec
from .view_complex_message import handle_tool as handle_view_complex_message_tool 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 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_jargon_tool_spec(),
get_query_memory_tool_spec(enabled=bool(global_config.memory.enable_memory_query_tool)), get_query_memory_tool_spec(enabled=bool(global_config.memory.enable_memory_query_tool)),
get_send_emoji_tool_spec(), 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_memory_tool_spec(enabled=True),
get_query_person_info_tool_spec(), get_query_person_info_tool_spec(),
get_send_emoji_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), "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), "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( "view_complex_message": lambda invocation, context=None: handle_view_complex_message_tool(
tool_ctx, tool_ctx,
invocation, invocation,

View File

@@ -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"
"- queryString必填。工具名、前缀或关键词。\n"
"- limitInteger可选。最多返回多少个匹配工具默认为 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),
},
)

View File

@@ -5,20 +5,18 @@ from datetime import datetime
from typing import Any, List, Optional, Sequence from typing import Any, List, Optional, Sequence
import asyncio import asyncio
import json
import random import random
from pydantic import BaseModel, Field as PydanticField
from rich.console import RenderableType from rich.console import RenderableType
from src.common.data_models.llm_service_data_models import LLMGenerationOptions from src.common.data_models.llm_service_data_models import LLMGenerationOptions
from src.common.logger import get_logger from src.common.logger import get_logger
from src.common.prompt_i18n import load_prompt from src.common.prompt_i18n import load_prompt
from src.common.utils.utils_session import SessionUtils from src.common.utils.utils_session import SessionUtils
from src.config.config import global_config 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.model_client.base_client import BaseClient
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType 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.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, ToolOption, normalize_tool_options
from src.plugin_runtime.hook_payloads import ( from src.plugin_runtime.hook_payloads import (
deserialize_prompt_messages, deserialize_prompt_messages,
@@ -54,13 +52,6 @@ class ChatResponse:
prompt_section: Optional[RenderableType] = None prompt_section: Optional[RenderableType] = None
class ToolFilterSelection(BaseModel):
"""工具筛选响应。"""
selected_tool_names: list[str] = PydanticField(default_factory=list)
"""经过预筛后保留的候选工具名称列表。"""
logger = get_logger("maisaka_chat_loop") logger = get_logger("maisaka_chat_loop")
@@ -217,10 +208,6 @@ class MaisakaChatLoopService:
else: else:
self._chat_system_prompt = chat_system_prompt self._chat_system_prompt = chat_system_prompt
self._llm_chat = LLMServiceClient(task_name="planner", request_type="maisaka_planner") 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 @property
def personality_prompt(self) -> str: def personality_prompt(self) -> str:
@@ -399,6 +386,7 @@ class MaisakaChatLoopService:
self, self,
selected_history: List[LLMContextMessage], selected_history: List[LLMContextMessage],
*, *,
injected_user_messages: Sequence[str] | None = None,
system_prompt: Optional[str] = None, system_prompt: Optional[str] = None,
) -> List[Message]: ) -> List[Message]:
"""构造发给大模型的消息列表。 """构造发给大模型的消息列表。
@@ -420,61 +408,19 @@ class MaisakaChatLoopService:
if llm_message is not None: if llm_message is not None:
messages.append(llm_message) 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 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 @staticmethod
def _build_tool_names_log_text(tool_definitions: Sequence[ToolDefinitionInput]) -> str: def _build_tool_names_log_text(tool_definitions: Sequence[ToolDefinitionInput]) -> str:
"""构造 planner 请求前的工具列表日志文本。 """构造 planner 请求前的工具列表日志文本。
@@ -503,211 +449,11 @@ class MaisakaChatLoopService:
return "".join(tool_names) 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( async def chat_loop_step(
self, self,
chat_history: List[LLMContextMessage], chat_history: List[LLMContextMessage],
*, *,
injected_user_messages: Sequence[str] | None = None,
request_kind: str = "planner", request_kind: str = "planner",
response_format: RespFormat | None = None, response_format: RespFormat | None = None,
tool_definitions: Sequence[ToolDefinitionInput] | None = None, tool_definitions: Sequence[ToolDefinitionInput] | None = None,
@@ -724,7 +470,10 @@ class MaisakaChatLoopService:
if not self._prompts_loaded: if not self._prompts_loaded:
await self.ensure_chat_prompt_loaded() await self.ensure_chat_prompt_loaded()
selected_history, selection_reason = self.select_llm_context_messages(chat_history) 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]: def message_factory(_client: BaseClient) -> List[Message]:
"""返回当前轮次已经构建好的请求消息。 """返回当前轮次已经构建好的请求消息。
@@ -744,8 +493,7 @@ class MaisakaChatLoopService:
all_tools = list(tool_definitions) all_tools = list(tool_definitions)
elif self._tool_registry is not None: elif self._tool_registry is not None:
tool_specs = await self._tool_registry.list_tools() 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 tool_specs]
all_tools = [tool_spec.to_llm_definition() for tool_spec in filtered_tool_specs]
else: else:
all_tools = [*get_builtin_tools(), *self._extra_tools] all_tools = [*get_builtin_tools(), *self._extra_tools]

View File

@@ -93,6 +93,7 @@ class MaisakaReasoningEngine:
async def _run_interruptible_planner( async def _run_interruptible_planner(
self, self,
*, *,
injected_user_messages: Optional[list[str]] = None,
tool_definitions: Optional[list[dict[str, Any]]] = None, tool_definitions: Optional[list[dict[str, Any]]] = None,
) -> Any: ) -> Any:
"""运行一轮可被新消息打断的主 planner 请求。""" """运行一轮可被新消息打断的主 planner 请求。"""
@@ -104,6 +105,7 @@ class MaisakaReasoningEngine:
try: try:
return await self._runtime._chat_loop_service.chat_loop_step( return await self._runtime._chat_loop_service.chat_loop_step(
self._runtime._chat_history, self._runtime._chat_history,
injected_user_messages=injected_user_messages,
tool_definitions=tool_definitions, tool_definitions=tool_definitions,
) )
except ReqAbortException: except ReqAbortException:
@@ -173,22 +175,34 @@ class MaisakaReasoningEngine:
except Exception: except Exception:
return self._build_timing_gate_fallback_prompt() return self._build_timing_gate_fallback_prompt()
async def _build_action_tool_definitions(self) -> list[dict[str, Any]]: async def _build_action_tool_definitions(self) -> tuple[list[dict[str, Any]], str]:
"""构造 Action Loop 阶段可见的工具定义。""" """构造 Action Loop 阶段可见的工具定义与 deferred tools 提示"""
if self._runtime._tool_registry is None: 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() tool_specs = await self._runtime._tool_registry.list_tools()
return [ visible_builtin_tool_specs: list[ToolSpec] = []
tool_spec.to_llm_definition() deferred_tool_specs: list[ToolSpec] = []
for tool_spec in tool_specs for tool_spec in tool_specs:
if tool_spec.name not in ACTION_HIDDEN_TOOL_NAMES if tool_spec.name in ACTION_HIDDEN_TOOL_NAMES:
and ( continue
tool_spec.provider_name != "maisaka_builtin" if tool_spec.provider_name == "maisaka_builtin":
or tool_spec.name in ACTION_BUILTIN_TOOL_NAMES 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( async def _invoke_tool_call(
self, self,
@@ -416,7 +430,7 @@ class MaisakaReasoningEngine:
) )
planner_started_at = time.time() 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( logger.info(
f"{self._runtime.log_prefix} 规划器开始执行: " f"{self._runtime.log_prefix} 规划器开始执行: "
f"回合={round_index + 1} " f"回合={round_index + 1} "
@@ -424,6 +438,7 @@ class MaisakaReasoningEngine:
f"开始时间={planner_started_at:.3f}" f"开始时间={planner_started_at:.3f}"
) )
response = await self._run_interruptible_planner( response = await self._run_interruptible_planner(
injected_user_messages=[deferred_tools_reminder] if deferred_tools_reminder else None,
tool_definitions=action_tool_definitions, tool_definitions=action_tool_definitions,
) )
planner_duration_ms = (time.time() - planner_started_at) * 1000 planner_duration_ms = (time.time() - planner_started_at) * 1000
@@ -1175,7 +1190,17 @@ class MaisakaReasoningEngine:
for tool_call in tool_calls: for tool_call in tool_calls:
invocation = self._build_tool_invocation(tool_call, latest_thought) invocation = self._build_tool_invocation(tool_call, latest_thought)
tool_started_at = time.time() 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 tool_duration_ms = (time.time() - tool_started_at) * 1000
await self._store_tool_execution_record( await self._store_tool_execution_record(
invocation, invocation,

View File

@@ -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.logger import get_logger
from src.common.utils.utils_config import ChatConfigUtils, ExpressionConfigUtils from src.common.utils.utils_config import ChatConfigUtils, ExpressionConfigUtils
from src.config.config import global_config 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.expression_learner import ExpressionLearner
from src.learners.jargon_miner import JargonMiner from src.learners.jargon_miner import JargonMiner
from src.llm_models.payload_content.resp_format import RespFormat 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_flag: Optional[asyncio.Event] = None
self._planner_interrupt_requested = False self._planner_interrupt_requested = False
self._planner_interrupt_consecutive_count = 0 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( self._planner_interrupt_max_consecutive_count = max(
0, 0,
int(global_config.chat.planner_interrupt_max_consecutive_count), 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, 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 = [
"<system-reminder>",
"以下工具当前未直接暴露给你,但可以通过 tool_search 工具发现并在后续轮次中使用:",
*tool_lines,
"",
"如需其中某个工具,请先调用 tool_search。tool_search 只负责发现工具,不直接执行业务。",
"</system-reminder>",
]
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: def _has_pending_messages(self) -> bool:
return self._last_processed_index < len(self.message_cache) return self._last_processed_index < len(self.message_cache)

View File

@@ -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 "<system-reminder>" 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=["<system-reminder>\n1. plugin_beta\n</system-reminder>"],
)
assert len(messages) == 2
assert messages[0].role == RoleType.System
assert messages[1].role == RoleType.User
assert messages[1].content == "<system-reminder>\n1. plugin_beta\n</system-reminder>"

View File

@@ -12,3 +12,10 @@ def test_wait_tool_not_available_in_action_stage() -> None:
assert "wait" not in tool_names assert "wait" not in tool_names
assert "finish" 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