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