feat:新增sub_agent,暂时用于emoji选择;修改部分配置位置

This commit is contained in:
SengokuCola
2026-04-03 21:57:35 +08:00
parent ce580d1f8b
commit 07597bc1d7
9 changed files with 300 additions and 88 deletions

View File

@@ -1,5 +1,6 @@
"""Maisaka 表情工具内置能力。"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Optional, Sequence
@@ -17,6 +18,11 @@ from .emoji_manager import _serialize_emoji_for_hook, emoji_manager, emoji_manag
logger = get_logger("emoji_maisaka_tool")
EmojiSelector = Callable[
[str, str, Sequence[str] | None, int],
Awaitable[tuple[MaiEmoji | None, str]],
]
@dataclass(slots=True)
class MaisakaEmojiSendResult:
@@ -198,13 +204,14 @@ async def send_emoji_for_maisaka(
requested_emotion: str = "",
reasoning: str = "",
context_texts: Sequence[str] | None = None,
emoji_selector: EmojiSelector | None = None,
) -> MaisakaEmojiSendResult:
"""为 Maisaka 选择并发送一个表情。"""
normalized_requested_emotion = requested_emotion.strip()
normalized_reasoning = reasoning.strip()
normalized_context_texts = _normalize_context_texts(context_texts)
sample_size = 30
sample_size = 20
before_select_result = await _get_runtime_manager().invoke_hook(
"emoji.maisaka.before_select",
@@ -232,12 +239,20 @@ async def send_emoji_for_maisaka(
normalized_context_texts = _normalize_context_texts(before_select_kwargs.get("context_texts"))
sample_size = _coerce_positive_int(before_select_kwargs.get("sample_size"), sample_size)
selected_emoji, matched_emotion = await select_emoji_for_maisaka(
requested_emotion=normalized_requested_emotion,
reasoning=normalized_reasoning,
context_texts=normalized_context_texts,
sample_size=sample_size,
)
if emoji_selector is None:
selected_emoji, matched_emotion = await select_emoji_for_maisaka(
requested_emotion=normalized_requested_emotion,
reasoning=normalized_reasoning,
context_texts=normalized_context_texts,
sample_size=sample_size,
)
else:
selected_emoji, matched_emotion = await emoji_selector(
normalized_requested_emotion,
normalized_reasoning,
normalized_context_texts,
sample_size,
)
after_select_result = await _get_runtime_manager().invoke_hook(
"emoji.maisaka.after_select",
stream_id=stream_id,

View File

@@ -151,7 +151,7 @@ class MaisakaReplyGenerator:
content = self._normalize_content(content_body)
if not content:
continue
visible_speaker = speaker_name or global_config.maisaka.user_name.strip() or "User"
visible_speaker = speaker_name or global_config.maisaka.cli_user_name.strip() or "User"
parts.append(f"{timestamp} {visible_speaker}: {content}")
continue

View File

@@ -162,7 +162,7 @@ class MaisakaReplyGenerator:
def _build_history_messages(self, chat_history: List[LLMContextMessage]) -> List[Message]:
"""将 replyer 上下文拆成多条 LLM 消息。"""
bot_nickname = global_config.bot.nickname.strip() or "Bot"
default_user_name = global_config.maisaka.user_name.strip() or "User"
default_user_name = global_config.maisaka.cli_user_name.strip() or "User"
messages: List[Message] = []
for message in chat_history:

View File

@@ -67,7 +67,7 @@ class BufferCLI:
timestamp=timestamp,
platform=BufferCLI._CLI_PLATFORM,
)
user_name = global_config.maisaka.user_name.strip() or "用户"
user_name = global_config.maisaka.cli_user_name.strip() or "用户"
message.message_info = MessageInfo(
user_info=UserInfo(
user_id=BufferCLI._CLI_USER_ID,

View File

@@ -282,7 +282,24 @@ class ChatConfig(ConfigBase):
"x-icon": "list",
},
)
"""_wrap_为指定聊天添加额外的 prompt 配置列表"""
direct_image_input: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "image",
},
)
"""是否直接输入图片"""
replyer_generator_type: Literal["legacy", "multi"] = Field(
default="legacy",
json_schema_extra={
"x-widget": "select",
"x-icon": "git-branch",
},
)
"""Maisaka replyer 生成器类型legacy旧版单 prompt/ multi多消息版"""
enable_talk_value_rules: bool = Field(
default=True,
@@ -964,6 +981,14 @@ class DebugConfig(ConfigBase):
"x-icon": "brain",
},
)
show_maisaka_thinking: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "brain",
},
)
"""是否显示回复器推理"""
show_jargon_prompt: bool = Field(
@@ -1427,16 +1452,7 @@ class MaiSakaConfig(ConfigBase):
},
)
"""启用知识库模块"""
show_thinking: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "brain",
},
)
"""是否显示MaiSaka思考过程"""
user_name: str = Field(
cli_user_name: str = Field(
default="用户",
json_schema_extra={
"x-widget": "input",
@@ -1445,33 +1461,6 @@ class MaiSakaConfig(ConfigBase):
)
"""MaiSaka 使用的用户名称"""
direct_image_input: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "image",
},
)
"""是否直接输入图片"""
merge_user_messages: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "merge",
},
)
"""是否将新接收的用户发言合并为单个用户消息"""
replyer_generator_type: Literal["legacy", "multi"] = Field(
default="legacy",
json_schema_extra={
"x-widget": "select",
"x-icon": "git-branch",
},
)
"""Maisaka replyer 生成器类型legacy旧版单 prompt/ multi多消息版"""
max_internal_rounds: int = Field(
default=6,
ge=1,
@@ -1511,14 +1500,14 @@ class MaiSakaConfig(ConfigBase):
)
"""工具筛选阶段最多保留的非内置工具数量"""
terminal_image_display_mode: Literal["legacy", "path_link"] = Field(
default="legacy",
show_image_path: bool = Field(
default=True,
json_schema_extra={
"x-widget": "select",
"x-widget": "switch",
"x-icon": "image",
},
)
"""图片展示模式legacy仅显示元信息/ path_link可点击本地路径"""
"""是否显示图片本地路径"""
class MCPAuthorizationConfig(ConfigBase):

View File

@@ -1,16 +1,40 @@
"""send_emoji 内置工具。"""
from datetime import datetime
from random import sample
from secrets import token_hex
from typing import Any, Dict, Optional
import asyncio
from pydantic import BaseModel, Field as PydanticField
from src.chat.emoji_system.emoji_manager import emoji_manager
from src.chat.emoji_system.maisaka_tool import send_emoji_for_maisaka
from src.common.data_models.message_component_data_model import ImageComponent, MessageSequence, TextComponent
from src.common.data_models.image_data_model import MaiEmoji
from src.common.logger import get_logger
from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec
from src.maisaka.context_messages import LLMContextMessage
from src.llm_models.payload_content.resp_format import RespFormat, RespFormatType
from src.maisaka.context_messages import LLMContextMessage, ReferenceMessage, ReferenceMessageType, SessionBackedMessage
from .context import BuiltinToolRuntimeContext
logger = get_logger("maisaka_builtin_send_emoji")
_EMOJI_SUB_AGENT_CONTEXT_LIMIT = 12
_EMOJI_SUB_AGENT_MAX_TOKENS = 240
_EMOJI_SUB_AGENT_SAMPLE_SIZE = 20
_EMOJI_SUCCESS_MESSAGE = "???????"
class EmojiSelectionResult(BaseModel):
"""表情包子代理的结构化选择结果。"""
emoji_id: str = PydanticField(default="", description="选中的候选表情包 ID。")
matched_emotion: str = PydanticField(default="", description="本次命中的情绪标签,可为空。")
reason: str = PydanticField(default="", description="简短选择理由。")
def get_tool_spec() -> ToolSpec:
"""获取 send_emoji 工具声明。"""
@@ -33,6 +57,105 @@ def get_tool_spec() -> ToolSpec:
)
async def _build_emoji_candidate_message(emoji: MaiEmoji, candidate_id: str) -> SessionBackedMessage:
"""构建供子代理挑选的图片候选消息。"""
image_bytes = await asyncio.to_thread(emoji.full_path.read_bytes)
raw_message = MessageSequence(
[
TextComponent(f"ID: {candidate_id}"),
ImageComponent(binary_hash=str(emoji.file_hash or ""), binary_data=image_bytes),
]
)
return SessionBackedMessage(
raw_message=raw_message,
visible_text=f"ID: {candidate_id}",
timestamp=datetime.now(),
source_kind="emoji_candidate",
)
async def _select_emoji_with_sub_agent(
tool_ctx: BuiltinToolRuntimeContext,
requested_emotion: str,
reasoning: str,
context_texts: list[str],
sample_size: int,
) -> tuple[MaiEmoji | None, str]:
"""通过临时子代理从候选表情包中选出一个结果。"""
available_emojis = list(emoji_manager.emojis)
if not available_emojis:
return None, ""
effective_sample_size = min(max(sample_size, 1), _EMOJI_SUB_AGENT_SAMPLE_SIZE, len(available_emojis))
sampled_emojis = sample(available_emojis, effective_sample_size)
candidate_map: dict[str, MaiEmoji] = {}
candidate_messages: list[LLMContextMessage] = []
for emoji in sampled_emojis:
candidate_id = token_hex(4)
while candidate_id in candidate_map:
candidate_id = token_hex(4)
candidate_map[candidate_id] = emoji
candidate_messages.append(await _build_emoji_candidate_message(emoji, candidate_id))
context_text = "\n".join(context_texts[-5:]) if context_texts else "(暂无额外上下文)"
system_prompt = (
"你是 Maisaka 的临时表情包选择子代理。\n"
"你会收到一段群聊上下文,以及若干条候选表情包消息。每条候选消息里都有一个临时 ID。\n"
"你的任务是根据上下文、当前语气和发送意图,从候选里选出最合适的一个表情包。\n"
"必须只从候选消息中选择,不能编造新的 ID。\n"
"如果提供了 requested_emotion请优先考虑与其接近的候选如果没有完全匹配则选择最符合上下文语气的候选。\n"
"你必须返回一个 JSON 对象json object不要输出任何 JSON 之外的内容。\n"
'返回格式固定为:{"emoji_id":"候选ID","matched_emotion":"情绪标签","reason":"简短理由"}'
)
prompt_message = ReferenceMessage(
content=(
f"[选择任务]\n"
f"requested_emotion: {requested_emotion or '未指定'}\n"
f"reasoning: {reasoning or '辅助表达当前语气和情绪'}\n"
f"recent_context:\n{context_text}\n"
'请只输出 JSON。'
),
timestamp=datetime.now(),
reference_type=ReferenceMessageType.TOOL_HINT,
remaining_uses_value=1,
display_prefix="[表情包选择任务]",
)
response = await tool_ctx.runtime.run_sub_agent(
context_message_limit=_EMOJI_SUB_AGENT_CONTEXT_LIMIT,
system_prompt=system_prompt,
extra_messages=[prompt_message, *candidate_messages],
max_tokens=_EMOJI_SUB_AGENT_MAX_TOKENS,
response_format=RespFormat(
format_type=RespFormatType.JSON_SCHEMA,
schema=EmojiSelectionResult,
),
)
try:
selection = EmojiSelectionResult.model_validate_json(response.content or "")
except Exception as exc:
logger.warning(f"{tool_ctx.runtime.log_prefix} 表情包子代理结果解析失败,将回退到候选首项: {exc}")
fallback_emoji = sampled_emojis[0] if sampled_emojis else None
return fallback_emoji, requested_emotion
selected_emoji = candidate_map.get(selection.emoji_id.strip())
if selected_emoji is None:
logger.warning(
f"{tool_ctx.runtime.log_prefix} 表情包子代理返回了无效 ID: {selection.emoji_id!r},将回退到候选首项"
)
fallback_emoji = sampled_emojis[0] if sampled_emojis else None
return fallback_emoji, requested_emotion
matched_emotion = selection.matched_emotion.strip()
if not matched_emotion:
matched_emotion = requested_emotion.strip()
return selected_emoji, matched_emotion
async def handle_tool(
tool_ctx: BuiltinToolRuntimeContext,
invocation: ToolInvocation,
@@ -64,6 +187,13 @@ async def handle_tool(
requested_emotion=emotion,
reasoning=tool_ctx.engine.last_reasoning_content,
context_texts=context_texts,
emoji_selector=lambda requested_emotion, reasoning, context_texts, sample_size: _select_emoji_with_sub_agent(
tool_ctx,
requested_emotion,
reasoning,
list(context_texts or []),
sample_size,
),
)
except Exception as exc:
logger.exception(f"{tool_ctx.runtime.log_prefix} 发送表情包时发生异常: {exc}")
@@ -74,28 +204,29 @@ async def handle_tool(
structured_content=structured_result,
)
structured_result["description"] = send_result.description
structured_result["emotion"] = list(send_result.emotions)
structured_result["matched_emotion"] = send_result.matched_emotion
structured_result["message"] = send_result.message
if send_result.success:
structured_result["message"] = _EMOJI_SUCCESS_MESSAGE
logger.info(
f"{tool_ctx.runtime.log_prefix} 表情包发送成功 "
f"描述={send_result.description!r} 情绪标签={send_result.emotions} "
f"请求情绪={emotion!r} 命中情绪={send_result.matched_emotion!r}"
f"{tool_ctx.runtime.log_prefix} ??????? "
f"??={send_result.description!r} ????={send_result.emotions} "
f"????={emotion!r} ????={send_result.matched_emotion!r}"
)
tool_ctx.append_sent_emoji_to_chat_history(
emoji_base64=send_result.emoji_base64,
success_message=send_result.message,
success_message=_EMOJI_SUCCESS_MESSAGE,
)
structured_result["success"] = True
return tool_ctx.build_success_result(
invocation.tool_name,
send_result.message,
_EMOJI_SUCCESS_MESSAGE,
structured_content=structured_result,
)
structured_result["description"] = send_result.description
structured_result["emotion"] = list(send_result.emotions)
structured_result["matched_emotion"] = send_result.matched_emotion
structured_result["message"] = send_result.message
logger.warning(
f"{tool_ctx.runtime.log_prefix} 表情包发送失败 "
f"请求情绪={emotion!r} 错误信息={send_result.message}"

View File

@@ -210,7 +210,7 @@ class MaisakaChatLoopService:
self._extra_tools: List[ToolOption] = []
self._interrupt_flag: asyncio.Event | None = None
self._tool_registry: ToolRegistry | None = None
self._prompts_loaded = False
self._prompts_loaded = chat_system_prompt is not None
self._prompt_load_lock = asyncio.Lock()
self._personality_prompt = self._build_personality_prompt()
if chat_system_prompt is None:
@@ -392,7 +392,12 @@ class MaisakaChatLoopService:
"""设置当前 planner 请求使用的中断标记。"""
self._interrupt_flag = interrupt_flag
def _build_request_messages(self, selected_history: List[LLMContextMessage]) -> List[Message]:
def _build_request_messages(
self,
selected_history: List[LLMContextMessage],
*,
system_prompt: Optional[str] = None,
) -> List[Message]:
"""构造发给大模型的消息列表。
Args:
@@ -404,7 +409,7 @@ class MaisakaChatLoopService:
messages: List[Message] = []
system_msg = MessageBuilder().set_role(RoleType.System)
system_msg.add_text_content(self._chat_system_prompt)
system_msg.add_text_content(system_prompt if system_prompt is not None else self._chat_system_prompt)
messages.append(system_msg.build())
for msg in selected_history:
@@ -691,7 +696,13 @@ class MaisakaChatLoopService:
return extract_category_ids_from_result(generation_result.response or "")
async def chat_loop_step(self, chat_history: List[LLMContextMessage]) -> ChatResponse:
async def chat_loop_step(
self,
chat_history: List[LLMContextMessage],
*,
response_format: RespFormat | None = None,
tool_definitions: Sequence[ToolDefinitionInput] | None = None,
) -> ChatResponse:
"""执行一轮 Maisaka 规划器请求。
Args:
@@ -701,8 +712,9 @@ class MaisakaChatLoopService:
ChatResponse: 本轮规划器返回结果。
"""
await self.ensure_chat_prompt_loaded()
selected_history, selection_reason = self._select_llm_context_messages(chat_history)
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)
def message_factory(_client: BaseClient) -> List[Message]:
@@ -719,7 +731,9 @@ class MaisakaChatLoopService:
return built_messages
all_tools: List[ToolDefinitionInput]
if self._tool_registry is not None:
if tool_definitions is not None:
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]
@@ -748,10 +762,10 @@ class MaisakaChatLoopService:
ordered_panels = PromptCLIVisualizer.build_prompt_panels(
built_messages,
image_display_mode=global_config.maisaka.terminal_image_display_mode,
image_display_mode="path_link" if global_config.maisaka.show_image_path else "legacy",
)
if global_config.maisaka.show_thinking and ordered_panels:
if global_config.debug.show_maisaka_thinking and ordered_panels:
console.print(
Panel(
Group(*ordered_panels),
@@ -776,6 +790,7 @@ class MaisakaChatLoopService:
tool_options=all_tools if all_tools else None,
temperature=self._temperature,
max_tokens=self._max_tokens,
response_format=response_format,
interrupt_flag=self._interrupt_flag,
),
)
@@ -837,6 +852,40 @@ class MaisakaChatLoopService:
total_tokens=total_tokens,
)
@staticmethod
def select_llm_context_messages(
chat_history: List[LLMContextMessage],
*,
max_context_size: Optional[int] = None,
) -> tuple[List[LLMContextMessage], str]:
"""??????? LLM ???????"""
effective_context_size = max(1, int(max_context_size or global_config.chat.max_context_size))
selected_indices: List[int] = []
counted_message_count = 0
for index in range(len(chat_history) - 1, -1, -1):
message = chat_history[index]
if message.to_llm_message() is None:
continue
selected_indices.append(index)
if message.count_in_context:
counted_message_count += 1
if counted_message_count >= effective_context_size:
break
if not selected_indices:
return [], f"???????? {effective_context_size} ? user/assistant??? 0 ??"
selected_indices.reverse()
selected_history = [chat_history[index] for index in selected_indices]
selected_history = MaisakaChatLoopService._drop_leading_orphan_tool_results(selected_history)
return (
selected_history,
f"???????? {effective_context_size} ? user/assistant??????????? {len(selected_history)} ?",
)
@staticmethod
def _select_llm_context_messages(chat_history: List[LLMContextMessage]) -> tuple[List[LLMContextMessage], str]:
"""选择真正发送给 LLM 的上下文消息。
@@ -905,4 +954,4 @@ class MaisakaChatLoopService:
if first_valid_index == 0:
return selected_history
return selected_history[first_valid_index:]
return selected_history[first_valid_index:]

View File

@@ -266,7 +266,7 @@ class MaisakaReasoningEngine:
source_sequence = message.raw_message
planner_components = clone_message_sequence(source_sequence).components
if global_config.maisaka.direct_image_input:
if global_config.chat.direct_image_input:
await self._hydrate_visual_components(planner_components)
if planner_components and isinstance(planner_components[0], TextComponent):
planner_components[0].text = planner_prefix + planner_components[0].text
@@ -610,16 +610,8 @@ class MaisakaReasoningEngine:
return f"你尝试回复消息 {target_message_id or 'unknown'},但失败了:{error_text}"
if invocation.tool_name == "send_emoji":
description = str(structured_content.get("description") or "").strip()
emotion_list = structured_content.get("emotion")
if isinstance(emotion_list, list):
emotion_text = "".join(str(item).strip() for item in emotion_list if str(item).strip())
else:
emotion_text = ""
if result.success and description:
if emotion_text:
return f"你发送了表情包:{description}(情绪:{emotion_text}"
return f"你发送了表情包:{description}"
if result.success:
return "你发送了表情包。"
return f"你尝试发送表情包,但失败了:{self._truncate_tool_record_text(result.error_message or history_content, 120)}"
if invocation.tool_name == "wait":

View File

@@ -1,6 +1,6 @@
"""Maisaka 非 CLI 运行时。"""
from typing import Any, Literal, Optional
from typing import Any, Literal, Optional, Sequence
import asyncio
import time
@@ -20,12 +20,14 @@ from src.core.tooling import ToolRegistry
from src.know_u.knowledge import KnowledgeLearner
from src.learners.expression_learner import ExpressionLearner
from src.learners.jargon_miner import JargonMiner
from src.llm_models.payload_content.resp_format import RespFormat
from src.llm_models.payload_content.tool_option import ToolDefinitionInput
from src.mcp_module import MCPManager
from src.mcp_module.host_llm_bridge import MCPHostLLMBridge
from src.mcp_module.provider import MCPToolProvider
from src.plugin_runtime.tool_provider import PluginToolProvider
from .chat_loop_service import MaisakaChatLoopService
from .chat_loop_service import ChatResponse, MaisakaChatLoopService
from .context_messages import LLMContextMessage
from .reasoning_engine import MaisakaReasoningEngine
from .tool_provider import MaisakaBuiltinToolProvider
@@ -197,6 +199,40 @@ class MaisakaHeartFlowChatting:
self._tool_registry.register_provider(PluginToolProvider())
self._chat_loop_service.set_tool_registry(self._tool_registry)
async def run_sub_agent(
self,
*,
context_message_limit: int,
system_prompt: str,
extra_messages: Optional[Sequence[LLMContextMessage]] = None,
max_tokens: int = 512,
response_format: RespFormat | None = None,
temperature: float = 0.2,
tool_definitions: Optional[Sequence[ToolDefinitionInput]] = None,
) -> ChatResponse:
"""运行一个复制上下文的临时子代理,并在完成后立即销毁。"""
selected_history, _ = MaisakaChatLoopService.select_llm_context_messages(
self._chat_history,
max_context_size=context_message_limit,
)
sub_agent_history = list(selected_history)
if extra_messages:
sub_agent_history.extend(list(extra_messages))
sub_agent = MaisakaChatLoopService(
chat_system_prompt=system_prompt,
session_id=self.session_id,
is_group_chat=self.chat_stream.is_group_session,
temperature=temperature,
max_tokens=max_tokens,
)
return await sub_agent.chat_loop_step(
sub_agent_history,
response_format=response_format,
tool_definitions=[] if tool_definitions is None else tool_definitions,
)
async def _main_loop(self) -> None:
try:
while self._running:
@@ -421,7 +457,7 @@ class MaisakaHeartFlowChatting:
if self.chat_stream.user_id:
return UserInfo(
user_id=self.chat_stream.user_id,
user_nickname=global_config.maisaka.user_name.strip() or "用户",
user_nickname=global_config.maisaka.cli_user_name.strip() or "用户",
user_cardname=None,
)
return UserInfo(user_id="maisaka_user", user_nickname="用户", user_cardname=None)
@@ -455,7 +491,7 @@ class MaisakaHeartFlowChatting:
tool_results: Optional[list[str]] = None,
) -> None:
"""在终端展示当前聊天流的上下文占用、规划结果与工具摘要。"""
if not global_config.maisaka.show_thinking:
if not global_config.debug.show_maisaka_thinking:
return
session_name = chat_manager.get_session_name(self.session_id) or self.session_id