1428 lines
57 KiB
Python
1428 lines
57 KiB
Python
"""Maisaka 非 CLI 运行时。"""
|
||
|
||
from collections import deque
|
||
from math import ceil
|
||
from typing import Any, Literal, Optional, Sequence
|
||
|
||
import asyncio
|
||
import time
|
||
|
||
from rich.console import Group, RenderableType
|
||
from rich.panel import Panel
|
||
from rich.pretty import Pretty
|
||
from rich.text import Text
|
||
|
||
from src.cli.console import console
|
||
from src.chat.heart_flow.heartFC_utils import CycleDetail
|
||
from src.chat.message_receive.chat_manager import BotChatSession, chat_manager
|
||
from src.chat.message_receive.message import SessionMessage
|
||
from src.chat.utils.utils import is_mentioned_bot_in_message
|
||
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, 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
|
||
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 src.plugin_runtime.hook_payloads import deserialize_prompt_messages
|
||
|
||
from .chat_loop_service import ChatResponse, MaisakaChatLoopService
|
||
from .context_messages import LLMContextMessage
|
||
from .display.display_utils import build_tool_call_summary_lines, format_token_count
|
||
from .display.prompt_cli_renderer import PromptCLIVisualizer
|
||
from .display.stage_status_board import remove_stage_status, update_stage_status
|
||
from .reasoning_engine import MaisakaReasoningEngine
|
||
from .tool_provider import MaisakaBuiltinToolProvider
|
||
|
||
logger = get_logger("maisaka_runtime")
|
||
|
||
MAX_INTERNAL_ROUNDS = 6
|
||
|
||
|
||
class MaisakaHeartFlowChatting:
|
||
"""会话级别的 Maisaka 运行时。"""
|
||
|
||
_STATE_RUNNING: Literal["running"] = "running"
|
||
_STATE_WAIT: Literal["wait"] = "wait"
|
||
_STATE_STOP: Literal["stop"] = "stop"
|
||
|
||
def __init__(self, session_id: str):
|
||
self.session_id = session_id
|
||
chat_stream = chat_manager.get_session_by_session_id(session_id)
|
||
if chat_stream is None:
|
||
raise ValueError(f"未找到会话 {session_id} 对应的 Maisaka 运行时")
|
||
self.chat_stream: BotChatSession = chat_stream
|
||
|
||
session_name = chat_manager.get_session_name(session_id) or session_id
|
||
self.session_name = session_name
|
||
self.log_prefix = f"[{session_name}]"
|
||
self._chat_loop_service = MaisakaChatLoopService(
|
||
session_id=session_id,
|
||
is_group_chat=self.chat_stream.is_group_session,
|
||
)
|
||
self._chat_history: list[LLMContextMessage] = []
|
||
self.history_loop: list[CycleDetail] = []
|
||
|
||
# Keep all original messages for batching and later learning.
|
||
self.message_cache: list[SessionMessage] = []
|
||
self._last_processed_index = 0
|
||
self._internal_turn_queue: asyncio.Queue[Literal["message", "timeout"]] = asyncio.Queue()
|
||
|
||
self._mcp_manager: Optional[MCPManager] = None
|
||
self._mcp_host_bridge: Optional[MCPHostLLMBridge] = None
|
||
self._current_cycle_detail: Optional[CycleDetail] = None
|
||
self._source_messages_by_id: dict[str, SessionMessage] = {}
|
||
self._running = False
|
||
self._cycle_counter = 0
|
||
self._internal_loop_task: Optional[asyncio.Task] = None
|
||
self._message_turn_scheduled = False
|
||
self._deferred_message_turn_task: Optional[asyncio.Task[None]] = None
|
||
self._message_debounce_seconds = 1.0
|
||
self._message_debounce_required = False
|
||
self._message_received_at_by_id: dict[str, float] = {}
|
||
self._last_message_received_at = 0.0
|
||
self._talk_frequency_adjust = 1.0
|
||
self._reply_latency_measurement_started_at: Optional[float] = None
|
||
self._recent_reply_latencies: deque[tuple[float, float]] = deque()
|
||
self._wait_timeout_task: Optional[asyncio.Task[None]] = None
|
||
self._max_internal_rounds = MAX_INTERNAL_ROUNDS
|
||
self._max_context_size = max(1, int(global_config.chat.max_context_size))
|
||
self._agent_state: Literal["running", "wait", "stop"] = self._STATE_STOP
|
||
self._pending_wait_tool_call_id: Optional[str] = None
|
||
self._force_next_timing_continue = False
|
||
self._force_next_timing_message_id = ""
|
||
self._force_next_timing_reason = ""
|
||
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),
|
||
)
|
||
|
||
expr_use, jargon_learn, expr_learn = ExpressionConfigUtils.get_expression_config_for_chat(session_id)
|
||
self._enable_expression_use = expr_use
|
||
self._enable_expression_learning = expr_learn
|
||
self._enable_jargon_learning = jargon_learn
|
||
self._min_extraction_interval = 30
|
||
self._last_expression_extraction_time = 0.0
|
||
self._expression_learner = ExpressionLearner(session_id)
|
||
self._jargon_miner = JargonMiner(session_id, session_name=session_name)
|
||
|
||
self._reasoning_engine = MaisakaReasoningEngine(self)
|
||
self._tool_registry = ToolRegistry()
|
||
self._register_tool_providers()
|
||
|
||
def _update_stage_status(self, stage: str, detail: str = "", *, round_text: str = "") -> None:
|
||
"""更新当前会话的阶段状态。"""
|
||
|
||
update_stage_status(
|
||
session_id=self.session_id,
|
||
session_name=self.session_name,
|
||
stage=stage,
|
||
detail=detail,
|
||
round_text=round_text,
|
||
agent_state=self._agent_state,
|
||
)
|
||
|
||
async def start(self) -> None:
|
||
"""启动运行时主循环。"""
|
||
if self._running:
|
||
self._ensure_background_tasks_running()
|
||
return
|
||
|
||
if global_config.mcp.enable:
|
||
await self._init_mcp()
|
||
|
||
self._running = True
|
||
self._ensure_background_tasks_running()
|
||
self._schedule_message_turn()
|
||
self._update_stage_status("空闲", "等待消息触发")
|
||
logger.info(f"{self.log_prefix} Maisaka 运行时已启动")
|
||
|
||
async def stop(self) -> None:
|
||
"""停止运行时主循环。"""
|
||
if not self._running:
|
||
return
|
||
|
||
self._running = False
|
||
self._message_turn_scheduled = False
|
||
self._message_debounce_required = False
|
||
self._cancel_deferred_message_turn_task()
|
||
self._cancel_wait_timeout_task()
|
||
while not self._internal_turn_queue.empty():
|
||
_ = self._internal_turn_queue.get_nowait()
|
||
|
||
if self._internal_loop_task is not None:
|
||
self._internal_loop_task.cancel()
|
||
try:
|
||
await self._internal_loop_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
finally:
|
||
self._internal_loop_task = None
|
||
|
||
await self._tool_registry.close()
|
||
self._mcp_manager = None
|
||
self._mcp_host_bridge = None
|
||
remove_stage_status(self.session_id)
|
||
|
||
logger.info(f"{self.log_prefix} Maisaka 运行时已停止")
|
||
|
||
def adjust_talk_frequency(self, frequency: float) -> None:
|
||
"""调整当前会话的回复频率倍率。"""
|
||
self._talk_frequency_adjust = max(0.01, float(frequency))
|
||
self._schedule_message_turn()
|
||
|
||
async def register_message(self, message: SessionMessage) -> None:
|
||
"""缓存一条新消息并唤醒主循环。"""
|
||
if self._running:
|
||
self._ensure_background_tasks_running()
|
||
received_at = time.time()
|
||
self._last_message_received_at = received_at
|
||
self._update_message_trigger_state(message)
|
||
self.message_cache.append(message)
|
||
self._message_received_at_by_id[message.message_id] = received_at
|
||
self._source_messages_by_id[message.message_id] = message
|
||
if self._agent_state == self._STATE_RUNNING:
|
||
self._message_debounce_required = True
|
||
if self._agent_state == self._STATE_RUNNING and self._planner_interrupt_flag is not None:
|
||
if self._planner_interrupt_requested:
|
||
logger.info(
|
||
f"{self.log_prefix} 收到新消息,但当前请求已发起过一次规划器打断,"
|
||
f"本次不重复打断; 消息编号={message.message_id} "
|
||
f"连续打断次数={self._planner_interrupt_consecutive_count}/"
|
||
f"{self._planner_interrupt_max_consecutive_count}"
|
||
)
|
||
elif self._planner_interrupt_consecutive_count >= self._planner_interrupt_max_consecutive_count:
|
||
logger.info(
|
||
f"{self.log_prefix} 收到新消息,但已达到规划器连续打断上限,"
|
||
f"将等待当前请求自然完成; 消息编号={message.message_id} "
|
||
f"连续打断次数={self._planner_interrupt_consecutive_count}/"
|
||
f"{self._planner_interrupt_max_consecutive_count}"
|
||
)
|
||
else:
|
||
self._planner_interrupt_requested = True
|
||
self._planner_interrupt_consecutive_count += 1
|
||
logger.info(
|
||
f"{self.log_prefix} 收到新消息,发起规划器打断; "
|
||
f"消息编号={message.message_id} 缓存条数={len(self.message_cache)} "
|
||
f"时间戳={time.time():.3f} "
|
||
f"连续打断次数={self._planner_interrupt_consecutive_count}/"
|
||
f"{self._planner_interrupt_max_consecutive_count}"
|
||
)
|
||
self._planner_interrupt_flag.set()
|
||
if self._running:
|
||
self._schedule_message_turn()
|
||
|
||
def _get_effective_reply_frequency(self) -> float:
|
||
"""返回当前会话生效的回复频率。"""
|
||
talk_value = max(0.01, float(ChatConfigUtils.get_talk_value(self.session_id)))
|
||
return max(0.01, talk_value * self._talk_frequency_adjust)
|
||
|
||
def _get_message_trigger_threshold(self) -> int:
|
||
"""根据回复频率折算出触发一轮循环所需的消息数。"""
|
||
effective_frequency = min(1.0, self._get_effective_reply_frequency())
|
||
return max(1, int(ceil(1.0 / effective_frequency)))
|
||
|
||
def _get_pending_message_count(self) -> int:
|
||
"""统计当前尚未进入内部循环的新消息数量。"""
|
||
pending_messages = self.message_cache[self._last_processed_index :]
|
||
if not pending_messages:
|
||
return 0
|
||
|
||
seen_message_ids: set[str] = set()
|
||
for message in pending_messages:
|
||
seen_message_ids.add(message.message_id)
|
||
return len(seen_message_ids)
|
||
|
||
def _prune_recent_reply_latencies(self, now: Optional[float] = None) -> None:
|
||
"""仅保留最近 10 分钟内的回复时长记录。"""
|
||
current_time = time.time() if now is None else now
|
||
expire_before = current_time - 600.0
|
||
while self._recent_reply_latencies and self._recent_reply_latencies[0][0] < expire_before:
|
||
self._recent_reply_latencies.popleft()
|
||
|
||
def _get_recent_average_reply_latency(self) -> Optional[float]:
|
||
"""获取最近 10 分钟平均消息回复时长。"""
|
||
self._prune_recent_reply_latencies()
|
||
if not self._recent_reply_latencies:
|
||
return None
|
||
|
||
total_duration = sum(duration for _, duration in self._recent_reply_latencies)
|
||
return total_duration / len(self._recent_reply_latencies)
|
||
|
||
def _record_reply_sent(self) -> None:
|
||
"""在成功发送 reply 后记录本轮消息回复时长。"""
|
||
if self._reply_latency_measurement_started_at is None:
|
||
return
|
||
|
||
reply_duration = max(0.0, time.time() - self._reply_latency_measurement_started_at)
|
||
self._reply_latency_measurement_started_at = None
|
||
self._recent_reply_latencies.append((time.time(), reply_duration))
|
||
self._prune_recent_reply_latencies()
|
||
logger.info(
|
||
f"{self.log_prefix} 已记录消息回复时长: {reply_duration:.2f} 秒 "
|
||
f"最近10分钟样本数={len(self._recent_reply_latencies)}"
|
||
)
|
||
|
||
def _should_trigger_message_turn_by_idle_compensation(
|
||
self,
|
||
*,
|
||
pending_count: int,
|
||
trigger_threshold: int,
|
||
) -> bool:
|
||
"""在新消息不足阈值时,按空窗时间折算补齐触发条件。"""
|
||
average_reply_latency = self._get_recent_average_reply_latency()
|
||
if average_reply_latency is None or average_reply_latency <= 0:
|
||
return False
|
||
|
||
idle_seconds = max(0.0, time.time() - self._last_message_received_at)
|
||
equivalent_message_count = pending_count + idle_seconds / average_reply_latency
|
||
return equivalent_message_count >= trigger_threshold
|
||
|
||
def _cancel_deferred_message_turn_task(self) -> None:
|
||
"""取消等待空窗补偿触发的延迟任务。"""
|
||
if self._deferred_message_turn_task is None:
|
||
return
|
||
self._deferred_message_turn_task.cancel()
|
||
self._deferred_message_turn_task = None
|
||
|
||
async def _schedule_deferred_message_turn(self, delay_seconds: float) -> None:
|
||
"""在预计满足空窗补偿条件时再次检查是否应触发循环。"""
|
||
try:
|
||
if delay_seconds > 0:
|
||
await asyncio.sleep(delay_seconds)
|
||
if not self._running:
|
||
return
|
||
self._schedule_message_turn()
|
||
except asyncio.CancelledError:
|
||
return
|
||
finally:
|
||
self._deferred_message_turn_task = None
|
||
|
||
def _update_message_trigger_state(self, message: SessionMessage) -> None:
|
||
"""补齐消息中的 @/提及 标记,并在命中时启用强制 continue。"""
|
||
|
||
detected_mentioned, detected_at, _ = is_mentioned_bot_in_message(message)
|
||
if detected_at:
|
||
message.is_at = True
|
||
if detected_mentioned:
|
||
message.is_mentioned = True
|
||
|
||
if not message.is_at and not message.is_mentioned:
|
||
return
|
||
|
||
self._arm_force_next_timing_continue(
|
||
message,
|
||
is_at=message.is_at,
|
||
is_mentioned=message.is_mentioned,
|
||
)
|
||
|
||
def _arm_force_next_timing_continue(
|
||
self,
|
||
message: SessionMessage,
|
||
*,
|
||
is_at: bool,
|
||
is_mentioned: bool,
|
||
) -> None:
|
||
"""在检测到 @ 或提及时,要求下一次 Timing Gate 直接 continue。"""
|
||
|
||
trigger_reason = "@消息" if is_at else "提及消息" if is_mentioned else "触发消息"
|
||
was_armed = self._force_next_timing_continue
|
||
self._force_next_timing_continue = True
|
||
self._force_next_timing_message_id = message.message_id
|
||
self._force_next_timing_reason = trigger_reason
|
||
|
||
if was_armed:
|
||
logger.info(
|
||
f"{self.log_prefix} 检测到新的{trigger_reason},刷新强制 continue 状态;"
|
||
f"消息编号={message.message_id}"
|
||
)
|
||
return
|
||
|
||
logger.info(
|
||
f"{self.log_prefix} 检测到{trigger_reason},下一次 Timing Gate 将直接视作 continue;"
|
||
f"消息编号={message.message_id}"
|
||
)
|
||
|
||
def _consume_force_next_timing_continue_reason(self) -> str | None:
|
||
"""消费一次性 Timing Gate continue 状态,并返回原因描述。"""
|
||
|
||
if not self._force_next_timing_continue:
|
||
return None
|
||
|
||
trigger_reason = self._force_next_timing_reason or "@/提及消息"
|
||
trigger_message_id = self._force_next_timing_message_id or "unknown"
|
||
reason = (
|
||
f"检测到新的{trigger_reason}(消息编号={trigger_message_id}),"
|
||
"本轮直接跳过 Timing Gate 并视作 continue。"
|
||
)
|
||
logger.info(
|
||
f"{self.log_prefix} 已结束本次强制 continue,恢复 Timing Gate;"
|
||
f"触发原因={trigger_reason} "
|
||
f"触发消息编号={trigger_message_id}"
|
||
)
|
||
self._force_next_timing_continue = False
|
||
self._force_next_timing_message_id = ""
|
||
self._force_next_timing_reason = ""
|
||
return reason
|
||
|
||
def _bind_planner_interrupt_flag(self, interrupt_flag: asyncio.Event) -> None:
|
||
"""绑定当前可打断请求使用的中断标记。"""
|
||
self._planner_interrupt_flag = interrupt_flag
|
||
self._planner_interrupt_requested = False
|
||
|
||
def _unbind_planner_interrupt_flag(
|
||
self,
|
||
interrupt_flag: asyncio.Event,
|
||
*,
|
||
interrupted: bool,
|
||
) -> None:
|
||
"""解绑当前可打断请求的中断标记,并维护连续打断计数。"""
|
||
if self._planner_interrupt_flag is interrupt_flag:
|
||
self._planner_interrupt_flag = None
|
||
self._planner_interrupt_requested = False
|
||
if not interrupted:
|
||
self._planner_interrupt_consecutive_count = 0
|
||
|
||
def _ensure_background_tasks_running(self) -> None:
|
||
"""确保后台任务仍在运行,若崩溃则自动拉起。"""
|
||
if not self._running:
|
||
return
|
||
|
||
if self._internal_loop_task is None or self._internal_loop_task.done():
|
||
if self._internal_loop_task is not None and not self._internal_loop_task.cancelled():
|
||
try:
|
||
exc = self._internal_loop_task.exception()
|
||
except Exception:
|
||
exc = None
|
||
if exc is not None:
|
||
logger.error(f"{self.log_prefix} 内部循环任务异常退出: {exc}")
|
||
self._internal_loop_task = asyncio.create_task(self._reasoning_engine.run_loop())
|
||
logger.warning(f"{self.log_prefix} 已重新拉起 Maisaka 内部循环任务")
|
||
|
||
def _register_tool_providers(self) -> None:
|
||
"""注册 Maisaka 运行时默认启用的工具 Provider。"""
|
||
|
||
self._tool_registry.register_provider(
|
||
MaisakaBuiltinToolProvider(self._reasoning_engine.build_builtin_tool_handlers())
|
||
)
|
||
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,
|
||
request_kind: str = "sub_agent",
|
||
extra_messages: Optional[Sequence[LLMContextMessage]] = None,
|
||
interrupt_flag: asyncio.Event | None = 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,
|
||
request_kind=request_kind,
|
||
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,
|
||
)
|
||
sub_agent.set_interrupt_flag(interrupt_flag)
|
||
return await sub_agent.chat_loop_step(
|
||
sub_agent_history,
|
||
request_kind=request_kind,
|
||
response_format=response_format,
|
||
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_specs = [
|
||
tool_spec
|
||
for tool_name, tool_spec in self.deferred_tool_specs_by_name.items()
|
||
if tool_name not in self.discovered_tool_names
|
||
]
|
||
if not undiscovered_tool_specs:
|
||
return ""
|
||
|
||
tool_lines: list[str] = []
|
||
for index, tool_spec in enumerate(undiscovered_tool_specs, start=1):
|
||
tool_name = tool_spec.name.strip()
|
||
tool_description = tool_spec.brief_description.strip()
|
||
if tool_description:
|
||
tool_lines.append(f"{index}. {tool_name}: {tool_description}")
|
||
else:
|
||
tool_lines.append(f"{index}. {tool_name}")
|
||
|
||
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:
|
||
return self._last_processed_index < len(self.message_cache)
|
||
|
||
def _schedule_message_turn(self) -> None:
|
||
"""为当前待处理消息安排一次内部 turn。"""
|
||
if self._agent_state == self._STATE_WAIT:
|
||
return
|
||
|
||
if not self._has_pending_messages() or self._message_turn_scheduled:
|
||
return
|
||
|
||
pending_count = self._get_pending_message_count()
|
||
if pending_count <= 0:
|
||
return
|
||
|
||
trigger_threshold = self._get_message_trigger_threshold()
|
||
if pending_count >= trigger_threshold or self._should_trigger_message_turn_by_idle_compensation(
|
||
pending_count=pending_count,
|
||
trigger_threshold=trigger_threshold,
|
||
):
|
||
self._cancel_deferred_message_turn_task()
|
||
self._message_turn_scheduled = True
|
||
self._internal_turn_queue.put_nowait("message")
|
||
return
|
||
|
||
average_reply_latency = self._get_recent_average_reply_latency()
|
||
if average_reply_latency is None or average_reply_latency <= 0:
|
||
return
|
||
|
||
idle_seconds = max(0.0, time.time() - self._last_message_received_at)
|
||
delay_seconds = max(0.0, (trigger_threshold - pending_count) * average_reply_latency - idle_seconds)
|
||
self._cancel_deferred_message_turn_task()
|
||
self._deferred_message_turn_task = asyncio.create_task(
|
||
self._schedule_deferred_message_turn(delay_seconds)
|
||
)
|
||
|
||
def _collect_pending_messages(self) -> list[SessionMessage]:
|
||
"""从消息缓存中收集一批尚未处理的消息。"""
|
||
start_index = self._last_processed_index
|
||
pending_messages = self.message_cache[start_index:]
|
||
if not pending_messages:
|
||
return []
|
||
|
||
unique_messages: list[SessionMessage] = []
|
||
seen_message_ids: set[str] = set()
|
||
for message in pending_messages:
|
||
message_id = message.message_id
|
||
if message_id in seen_message_ids:
|
||
continue
|
||
seen_message_ids.add(message_id)
|
||
unique_messages.append(message)
|
||
|
||
self._last_processed_index = len(self.message_cache)
|
||
# logger.info(
|
||
# f"{self.log_prefix} 已从消息缓存区[{start_index}:{self._last_processed_index}] "
|
||
# f"收集 {len(unique_messages)} 条新消息"
|
||
# )
|
||
if unique_messages and self._reply_latency_measurement_started_at is None:
|
||
self._reply_latency_measurement_started_at = min(
|
||
self._message_received_at_by_id.get(message.message_id, self._last_message_received_at)
|
||
for message in unique_messages
|
||
)
|
||
for message in unique_messages:
|
||
self._message_received_at_by_id.pop(message.message_id, None)
|
||
return unique_messages
|
||
|
||
async def _wait_for_message_quiet_period(self) -> None:
|
||
"""等待消息静默窗口结束后,再启动由打断触发的新一轮。"""
|
||
if not self._message_debounce_required:
|
||
return
|
||
|
||
if self._message_debounce_seconds <= 0:
|
||
self._message_debounce_required = False
|
||
return
|
||
|
||
while self._running:
|
||
elapsed = time.time() - self._last_message_received_at
|
||
remaining = self._message_debounce_seconds - elapsed
|
||
if remaining <= 0:
|
||
break
|
||
await asyncio.sleep(remaining)
|
||
|
||
self._message_debounce_required = False
|
||
|
||
def _enter_wait_state(self, seconds: Optional[float] = None, tool_call_id: Optional[str] = None) -> None:
|
||
"""切换到等待状态。"""
|
||
self._agent_state = self._STATE_WAIT
|
||
self._pending_wait_tool_call_id = tool_call_id
|
||
self._message_turn_scheduled = False
|
||
self._cancel_deferred_message_turn_task()
|
||
self._cancel_wait_timeout_task()
|
||
if seconds is not None:
|
||
self._wait_timeout_task = asyncio.create_task(
|
||
self._schedule_wait_timeout(seconds=seconds, tool_call_id=tool_call_id)
|
||
)
|
||
|
||
def _enter_stop_state(self) -> None:
|
||
"""切换到停止状态。"""
|
||
self._agent_state = self._STATE_STOP
|
||
self._pending_wait_tool_call_id = None
|
||
self._cancel_wait_timeout_task()
|
||
|
||
def _cancel_wait_timeout_task(self) -> None:
|
||
"""取消当前 wait 对应的超时任务。"""
|
||
if self._wait_timeout_task is None:
|
||
return
|
||
self._wait_timeout_task.cancel()
|
||
self._wait_timeout_task = None
|
||
|
||
async def _schedule_wait_timeout(self, seconds: float, tool_call_id: Optional[str]) -> None:
|
||
"""在 wait 到期后向内部循环投递 timeout 触发。"""
|
||
try:
|
||
if seconds > 0:
|
||
await asyncio.sleep(seconds)
|
||
if not self._running:
|
||
return
|
||
if self._agent_state != self._STATE_WAIT:
|
||
return
|
||
if self._pending_wait_tool_call_id != tool_call_id:
|
||
return
|
||
|
||
logger.info(f"{self.log_prefix} Maisaka 等待已超时")
|
||
self._agent_state = self._STATE_RUNNING
|
||
await self._internal_turn_queue.put("timeout")
|
||
except asyncio.CancelledError:
|
||
return
|
||
finally:
|
||
if self._wait_timeout_task is not None and self._pending_wait_tool_call_id == tool_call_id:
|
||
self._wait_timeout_task = None
|
||
|
||
async def _trigger_batch_learning(self, messages: list[SessionMessage]) -> None:
|
||
"""按同一批消息触发表达方式和黑话学习。"""
|
||
try:
|
||
await self._trigger_expression_learning(messages)
|
||
except Exception as exc:
|
||
logger.error(f"{self.log_prefix} 表达学习任务异常退出: {exc}")
|
||
|
||
def _should_trigger_learning(
|
||
self,
|
||
*,
|
||
enabled: bool,
|
||
feature_name: str,
|
||
last_extraction_time: float,
|
||
pending_count: int,
|
||
min_messages_for_extraction: int,
|
||
) -> bool:
|
||
"""判断周期性学习任务是否满足执行条件。"""
|
||
|
||
if not enabled:
|
||
logger.debug(f"{self.log_prefix} {feature_name}未启用,跳过本轮学习")
|
||
return False
|
||
|
||
elapsed = time.time() - last_extraction_time
|
||
if elapsed < self._min_extraction_interval:
|
||
logger.debug(
|
||
f"{self.log_prefix} {feature_name}触发间隔不足: "
|
||
f"已过={elapsed:.2f} 秒 阈值={self._min_extraction_interval} 秒"
|
||
)
|
||
return False
|
||
|
||
if pending_count < min_messages_for_extraction:
|
||
logger.debug(
|
||
f"{self.log_prefix} {feature_name}待处理消息不足: "
|
||
f"待处理={pending_count} 阈值={min_messages_for_extraction} "
|
||
f"缓存总量={len(self.message_cache)}"
|
||
)
|
||
return False
|
||
|
||
return True
|
||
|
||
async def _trigger_expression_learning(self, messages: list[SessionMessage]) -> None:
|
||
"""触发表达方式学习"""
|
||
pending_count = self._expression_learner.get_pending_count(self.message_cache)
|
||
if not self._should_trigger_learning(
|
||
enabled=self._enable_expression_learning,
|
||
feature_name="表达学习",
|
||
last_extraction_time=self._last_expression_extraction_time,
|
||
pending_count=pending_count,
|
||
min_messages_for_extraction=self._expression_learner.min_messages_for_extraction,
|
||
):
|
||
return
|
||
|
||
self._last_expression_extraction_time = time.time()
|
||
logger.info(
|
||
f"{self.log_prefix} 触发表达方式学习: "
|
||
f"消息数量={len(messages)} 待处理消息数量={pending_count} "
|
||
f"缓存总量={len(self.message_cache)} "
|
||
f"是否启用黑话学习={self._enable_jargon_learning}"
|
||
)
|
||
|
||
try:
|
||
jargon_miner = self._jargon_miner if self._enable_jargon_learning else None
|
||
learnt_style = await self._expression_learner.learn(self.message_cache, jargon_miner)
|
||
if learnt_style:
|
||
logger.info(f"{self.log_prefix} 表达方式学习成功")
|
||
else:
|
||
logger.debug(f"{self.log_prefix} 表达方式学习失败")
|
||
except Exception:
|
||
logger.exception(f"{self.log_prefix} 表达方式学习异常")
|
||
|
||
async def _init_mcp(self) -> None:
|
||
"""初始化 MCP 工具并注册到统一工具层。"""
|
||
self._mcp_host_bridge = MCPHostLLMBridge(
|
||
sampling_task_name=global_config.mcp.client.sampling.task_name,
|
||
)
|
||
self._mcp_manager = await MCPManager.from_app_config(
|
||
global_config.mcp,
|
||
host_callbacks=self._mcp_host_bridge.build_callbacks(),
|
||
)
|
||
if self._mcp_manager is None:
|
||
logger.info(f"{self.log_prefix} Maisaka MCP 管理器不可用")
|
||
return
|
||
|
||
mcp_tool_specs = self._mcp_manager.get_tool_specs()
|
||
if not mcp_tool_specs:
|
||
logger.info(f"{self.log_prefix} Maisaka 没有可供使用的 MCP 工具")
|
||
return
|
||
|
||
self._tool_registry.register_provider(MCPToolProvider(self._mcp_manager))
|
||
logger.info(
|
||
f"{self.log_prefix} 已向 Maisaka 加载 {len(mcp_tool_specs)} 个 MCP 工具。\n"
|
||
f"{self._mcp_manager.get_feature_summary()}"
|
||
)
|
||
|
||
def _build_runtime_user_info(self) -> UserInfo:
|
||
if self.chat_stream.user_id:
|
||
return UserInfo(
|
||
user_id=self.chat_stream.user_id,
|
||
user_nickname=global_config.maisaka.cli_user_name.strip() or "用户",
|
||
user_cardname=None,
|
||
)
|
||
return UserInfo(user_id="maisaka_user", user_nickname="用户", user_cardname=None)
|
||
|
||
def _build_group_info(self, message: Optional[SessionMessage] = None) -> Optional[GroupInfo]:
|
||
group_info = None
|
||
if message is not None:
|
||
group_info = message.message_info.group_info
|
||
elif self.chat_stream.context and self.chat_stream.context.message:
|
||
group_info = self.chat_stream.context.message.message_info.group_info
|
||
|
||
if group_info is None:
|
||
return None
|
||
|
||
return GroupInfo(group_id=group_info.group_id, group_name=group_info.group_name)
|
||
|
||
def _render_context_usage_panel(
|
||
self,
|
||
*,
|
||
cycle_id: Optional[int] = None,
|
||
time_records: Optional[dict[str, float]] = None,
|
||
timing_selected_history_count: Optional[int] = None,
|
||
timing_prompt_tokens: Optional[int] = None,
|
||
timing_action: str = "",
|
||
timing_response: str = "",
|
||
timing_tool_calls: Optional[list[Any]] = None,
|
||
timing_tool_results: Optional[list[str]] = None,
|
||
timing_tool_detail_results: Optional[list[dict[str, Any]]] = None,
|
||
timing_prompt_section: Optional[RenderableType] = None,
|
||
planner_selected_history_count: Optional[int] = None,
|
||
planner_prompt_tokens: Optional[int] = None,
|
||
planner_response: str = "",
|
||
planner_tool_calls: Optional[list[Any]] = None,
|
||
planner_tool_results: Optional[list[str]] = None,
|
||
planner_tool_detail_results: Optional[list[dict[str, Any]]] = None,
|
||
planner_prompt_section: Optional[RenderableType] = None,
|
||
planner_extra_lines: Optional[list[str]] = None,
|
||
) -> None:
|
||
"""在终端展示当前聊天流本轮 cycle 的最终结果。"""
|
||
if not global_config.debug.show_maisaka_thinking:
|
||
return
|
||
|
||
body_lines = [
|
||
f"聊天流名称:{getattr(self, 'session_name', self.session_id)}",
|
||
f"聊天流ID:{self.session_id}",
|
||
]
|
||
if cycle_id is not None:
|
||
body_lines.append(f"循环编号:{cycle_id}")
|
||
|
||
panel_subtitle = self._build_cycle_time_records_text(time_records or {})
|
||
renderables: list[RenderableType] = [Text("\n".join(body_lines))]
|
||
timing_panel = self._build_cycle_stage_panel(
|
||
title="Timing Gate",
|
||
border_style="bright_magenta",
|
||
selected_history_count=timing_selected_history_count,
|
||
prompt_tokens=timing_prompt_tokens,
|
||
response_text=timing_response,
|
||
prompt_section=timing_prompt_section,
|
||
extra_lines=[f"门控动作:{timing_action}"] if timing_action.strip() else None,
|
||
)
|
||
if timing_panel is not None:
|
||
renderables.append(timing_panel)
|
||
|
||
timing_tool_cards = self._build_tool_activity_cards(
|
||
stage_title="Timing Tool",
|
||
tool_calls=timing_tool_calls,
|
||
tool_results=timing_tool_results,
|
||
tool_detail_results=timing_tool_detail_results,
|
||
planner_style=False,
|
||
)
|
||
if timing_tool_cards:
|
||
renderables.extend(timing_tool_cards)
|
||
|
||
planner_panel = self._build_cycle_stage_panel(
|
||
title="Planner",
|
||
border_style="green",
|
||
selected_history_count=planner_selected_history_count,
|
||
prompt_tokens=planner_prompt_tokens,
|
||
response_text=planner_response,
|
||
prompt_section=planner_prompt_section,
|
||
extra_lines=planner_extra_lines,
|
||
)
|
||
if planner_panel is not None:
|
||
renderables.append(planner_panel)
|
||
|
||
planner_tool_cards = self._build_tool_activity_cards(
|
||
stage_title="Planner Tool",
|
||
tool_calls=planner_tool_calls,
|
||
tool_results=planner_tool_results,
|
||
tool_detail_results=planner_tool_detail_results,
|
||
planner_style=True,
|
||
)
|
||
if planner_tool_cards:
|
||
renderables.extend(planner_tool_cards)
|
||
|
||
console.print(
|
||
Panel(
|
||
Group(*renderables),
|
||
title="MaiSaka 循环",
|
||
subtitle=panel_subtitle,
|
||
border_style="bright_blue",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
def _build_cycle_stage_panel(
|
||
self,
|
||
*,
|
||
title: str,
|
||
border_style: str,
|
||
selected_history_count: Optional[int],
|
||
prompt_tokens: Optional[int],
|
||
response_text: str = "",
|
||
prompt_section: Optional[RenderableType] = None,
|
||
extra_lines: Optional[list[str]] = None,
|
||
) -> Optional[Panel]:
|
||
"""构建单个 cycle 阶段的展示卡片。"""
|
||
|
||
has_content = any([
|
||
selected_history_count is not None,
|
||
prompt_tokens is not None,
|
||
bool(response_text.strip()),
|
||
prompt_section is not None,
|
||
bool(extra_lines),
|
||
])
|
||
if not has_content:
|
||
return None
|
||
|
||
body_lines: list[str] = []
|
||
if selected_history_count is not None:
|
||
body_lines.append(f"上下文占用:{selected_history_count}/{self._max_context_size} 条")
|
||
if prompt_tokens is not None:
|
||
body_lines.append(f"本次请求token消耗:{format_token_count(prompt_tokens)}")
|
||
if extra_lines:
|
||
body_lines.extend([line for line in extra_lines if isinstance(line, str) and line.strip()])
|
||
|
||
renderables: list[RenderableType] = []
|
||
if body_lines:
|
||
renderables.append(Text("\n".join(body_lines)))
|
||
if prompt_section is not None:
|
||
renderables.append(prompt_section)
|
||
|
||
normalized_response = response_text.strip()
|
||
if normalized_response:
|
||
renderables.append(
|
||
Panel(
|
||
Text(normalized_response),
|
||
title="Maisaka 返回",
|
||
border_style=border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
return Panel(
|
||
Group(*renderables),
|
||
title=title,
|
||
border_style=border_style,
|
||
padding=(0, 1),
|
||
)
|
||
|
||
def _build_tool_activity_cards(
|
||
self,
|
||
*,
|
||
stage_title: str,
|
||
tool_calls: Optional[list[Any]] = None,
|
||
tool_results: Optional[list[str]] = None,
|
||
tool_detail_results: Optional[list[dict[str, Any]]] = None,
|
||
planner_style: bool = False,
|
||
) -> list[RenderableType]:
|
||
"""构建与阶段同级的工具执行卡片列表。"""
|
||
|
||
detail_results = tool_detail_results or []
|
||
cards = self._build_tool_detail_cards(
|
||
detail_results,
|
||
stage_title=stage_title,
|
||
planner_style=planner_style,
|
||
)
|
||
if cards:
|
||
return cards
|
||
|
||
# 兼容旧数据结构:若尚无 detail,则降级为简单文本卡片。
|
||
fallback_lines = self._filter_redundant_tool_results(
|
||
tool_results=tool_results or [],
|
||
tool_detail_results=detail_results,
|
||
)
|
||
if not fallback_lines and tool_calls:
|
||
fallback_lines = build_tool_call_summary_lines(tool_calls)
|
||
if not fallback_lines:
|
||
return []
|
||
|
||
fallback_border_style = "yellow"
|
||
return [
|
||
Panel(
|
||
Text("\n".join(fallback_lines)),
|
||
title=stage_title,
|
||
border_style=fallback_border_style,
|
||
padding=(0, 1),
|
||
)
|
||
]
|
||
|
||
@staticmethod
|
||
def _build_cycle_time_records_text(time_records: dict[str, float]) -> str:
|
||
"""构建循环最外层面板展示的阶段耗时文本。"""
|
||
|
||
if not time_records:
|
||
return "流程耗时:无"
|
||
|
||
label_map = {
|
||
"timing_gate": "Timing Gate",
|
||
"planner": "Planner",
|
||
"tool_calls": "工具执行",
|
||
}
|
||
ordered_keys = ["timing_gate", "planner", "tool_calls"]
|
||
|
||
parts: list[str] = []
|
||
for key in ordered_keys:
|
||
duration = time_records.get(key)
|
||
if isinstance(duration, (int, float)):
|
||
parts.append(f"{label_map.get(key, key)} {float(duration):.2f} s")
|
||
|
||
for key, duration in time_records.items():
|
||
if key in ordered_keys or not isinstance(duration, (int, float)):
|
||
continue
|
||
parts.append(f"{label_map.get(key, key)} {float(duration):.2f} s")
|
||
|
||
if not parts:
|
||
return "流程耗时:无"
|
||
return "流程耗时:" + " | ".join(parts)
|
||
|
||
@staticmethod
|
||
def _filter_redundant_tool_results(
|
||
*,
|
||
tool_results: list[str],
|
||
tool_detail_results: list[dict[str, Any]],
|
||
) -> list[str]:
|
||
"""过滤掉已经在详情卡片中展示过的工具摘要。"""
|
||
|
||
detailed_summaries = {
|
||
str(tool_result.get("summary") or "").strip()
|
||
for tool_result in tool_detail_results
|
||
if isinstance(tool_result.get("detail"), dict) and tool_result.get("detail")
|
||
}
|
||
return [
|
||
result.strip()
|
||
for result in tool_results
|
||
if isinstance(result, str)
|
||
and result.strip()
|
||
and result.strip() not in detailed_summaries
|
||
]
|
||
|
||
@staticmethod
|
||
def _build_tool_metrics_text(metrics: dict[str, Any]) -> str:
|
||
"""将工具监控 metrics 转换为便于 CLI 阅读的文本。"""
|
||
|
||
lines: list[str] = []
|
||
model_name = str(metrics.get("model_name") or "").strip()
|
||
if model_name:
|
||
lines.append(f"模型:{model_name}")
|
||
|
||
prompt_tokens = metrics.get("prompt_tokens")
|
||
completion_tokens = metrics.get("completion_tokens")
|
||
total_tokens = metrics.get("total_tokens")
|
||
if isinstance(prompt_tokens, int) or isinstance(completion_tokens, int) or isinstance(total_tokens, int):
|
||
lines.append(
|
||
"Token:"
|
||
f"输入 {format_token_count(int(prompt_tokens or 0))} / "
|
||
f"输出 {format_token_count(int(completion_tokens or 0))} / "
|
||
f"总计 {format_token_count(int(total_tokens or 0))}"
|
||
)
|
||
|
||
prompt_ms = metrics.get("prompt_ms")
|
||
llm_ms = metrics.get("llm_ms")
|
||
overall_ms = metrics.get("overall_ms")
|
||
timing_parts: list[str] = []
|
||
if isinstance(prompt_ms, (int, float)):
|
||
timing_parts.append(f"prompt {round(float(prompt_ms), 2)} ms")
|
||
if isinstance(llm_ms, (int, float)):
|
||
timing_parts.append(f"llm {round(float(llm_ms), 2)} ms")
|
||
if isinstance(overall_ms, (int, float)):
|
||
timing_parts.append(f"overall {round(float(overall_ms), 2)} ms")
|
||
if timing_parts:
|
||
lines.append("耗时:" + " / ".join(timing_parts))
|
||
|
||
return "\n".join(lines)
|
||
|
||
@staticmethod
|
||
def _get_tool_detail_labels(tool_name: str) -> dict[str, str]:
|
||
"""返回不同工具对应的详情区标题与预览类别。"""
|
||
|
||
normalized_tool_name = str(tool_name or "").strip().lower()
|
||
if normalized_tool_name == "reply":
|
||
return {
|
||
"prompt_title": "Reply Prompt",
|
||
"reasoning_title": "Reply 思考",
|
||
"output_title": "Reply 输出",
|
||
"prompt_category": "replyer",
|
||
"request_kind": "replyer",
|
||
}
|
||
if normalized_tool_name == "send_emoji":
|
||
return {
|
||
"prompt_title": "Emotion Prompt",
|
||
"reasoning_title": "Emotion 思考",
|
||
"output_title": "Emotion 输出",
|
||
"prompt_category": "emotion",
|
||
"request_kind": "emotion",
|
||
}
|
||
display_name = normalized_tool_name or "tool"
|
||
return {
|
||
"prompt_title": f"{display_name} Prompt",
|
||
"reasoning_title": f"{display_name} 思考",
|
||
"output_title": f"{display_name} 输出",
|
||
"prompt_category": display_name,
|
||
"request_kind": "sub_agent",
|
||
}
|
||
|
||
def _build_tool_prompt_access_panel(
|
||
self,
|
||
*,
|
||
tool_name: str,
|
||
prompt_text: str,
|
||
request_messages: Optional[list[Any]] = None,
|
||
tool_call_id: str,
|
||
border_style: str = "bright_yellow",
|
||
) -> Panel:
|
||
"""将工具 prompt 渲染为可点击查看的预览入口。"""
|
||
|
||
labels = self._get_tool_detail_labels(tool_name)
|
||
subtitle = f"会话ID: {self.session_id}"
|
||
if tool_call_id:
|
||
subtitle += f"\n调用ID: {tool_call_id}"
|
||
|
||
if isinstance(request_messages, list) and request_messages:
|
||
try:
|
||
normalized_messages = deserialize_prompt_messages(request_messages)
|
||
except Exception as exc:
|
||
logger.warning(f"工具 {tool_name} 的 request_messages 无法反序列化,已回退为文本预览: {exc}")
|
||
else:
|
||
return Panel(
|
||
PromptCLIVisualizer.build_prompt_access_panel(
|
||
normalized_messages,
|
||
category=labels["prompt_category"],
|
||
chat_id=self.session_id,
|
||
request_kind=labels["request_kind"],
|
||
selection_reason=subtitle,
|
||
image_display_mode="path_link" if global_config.maisaka.show_image_path else "legacy",
|
||
),
|
||
title=labels["prompt_title"],
|
||
border_style=border_style,
|
||
padding=(0, 1),
|
||
)
|
||
|
||
return Panel(
|
||
PromptCLIVisualizer.build_text_access_panel(
|
||
prompt_text,
|
||
category=labels["prompt_category"],
|
||
chat_id=self.session_id,
|
||
request_kind=labels["request_kind"],
|
||
subtitle=subtitle,
|
||
),
|
||
title=labels["prompt_title"],
|
||
border_style=border_style,
|
||
padding=(0, 1),
|
||
)
|
||
|
||
def _normalize_tool_card_body_lines(self, body: Any) -> list[str]:
|
||
"""将工具卡片正文规范化为行列表。"""
|
||
|
||
if isinstance(body, str):
|
||
return [line for line in body.splitlines() if line.strip()]
|
||
if isinstance(body, list):
|
||
return [
|
||
str(item).strip()
|
||
for item in body
|
||
if str(item).strip()
|
||
]
|
||
return []
|
||
|
||
def _build_custom_tool_sub_cards(
|
||
self,
|
||
sub_cards: Any,
|
||
*,
|
||
default_border_style: str,
|
||
) -> list[RenderableType]:
|
||
"""构建工具自定义子卡片。"""
|
||
|
||
if not isinstance(sub_cards, list):
|
||
return []
|
||
|
||
renderables: list[RenderableType] = []
|
||
for sub_card in sub_cards:
|
||
if not isinstance(sub_card, dict):
|
||
continue
|
||
title = str(sub_card.get("title") or "").strip() or "附加信息"
|
||
border_style = str(sub_card.get("border_style") or "").strip() or default_border_style
|
||
body_lines = self._normalize_tool_card_body_lines(
|
||
sub_card.get("body_lines", sub_card.get("content", ""))
|
||
)
|
||
if not body_lines:
|
||
continue
|
||
renderables.append(
|
||
Panel(
|
||
Text("\n".join(body_lines)),
|
||
title=title,
|
||
border_style=border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
return renderables
|
||
|
||
def _build_default_tool_detail_parts(
|
||
self,
|
||
*,
|
||
tool_name: str,
|
||
tool_call_id: str,
|
||
tool_args: Any,
|
||
summary: str,
|
||
duration_ms: Any,
|
||
detail: dict[str, Any],
|
||
planner_style: bool,
|
||
) -> list[RenderableType]:
|
||
"""构建工具卡片默认内容块。"""
|
||
|
||
argument_border_style = "yellow"
|
||
metrics_border_style = "bright_yellow"
|
||
prompt_border_style = "bright_yellow"
|
||
reasoning_border_style = "yellow"
|
||
output_border_style = "bright_yellow"
|
||
extra_info_border_style = "yellow"
|
||
detail_labels = self._get_tool_detail_labels(tool_name)
|
||
|
||
parts: list[RenderableType] = []
|
||
header_lines: list[str] = []
|
||
if summary:
|
||
header_lines.append(summary)
|
||
if tool_call_id:
|
||
header_lines.append(f"调用ID:{tool_call_id}")
|
||
if isinstance(duration_ms, (int, float)):
|
||
header_lines.append(f"执行耗时:{round(float(duration_ms), 2)} ms")
|
||
if header_lines:
|
||
parts.append(Text("\n".join(header_lines)))
|
||
|
||
if isinstance(tool_args, dict) and tool_args:
|
||
parts.append(
|
||
Panel(
|
||
Pretty(tool_args, expand_all=True),
|
||
title="工具参数",
|
||
border_style=argument_border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
metrics = detail.get("metrics")
|
||
if isinstance(metrics, dict):
|
||
metrics_text = self._build_tool_metrics_text(metrics)
|
||
if metrics_text:
|
||
parts.append(
|
||
Panel(
|
||
Text(metrics_text),
|
||
title="执行指标",
|
||
border_style=metrics_border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
prompt_text = str(detail.get("prompt_text") or "").strip()
|
||
if prompt_text:
|
||
parts.append(
|
||
self._build_tool_prompt_access_panel(
|
||
tool_name=tool_name,
|
||
prompt_text=prompt_text,
|
||
request_messages=detail.get("request_messages") if isinstance(detail.get("request_messages"), list) else None,
|
||
tool_call_id=tool_call_id,
|
||
border_style=prompt_border_style,
|
||
)
|
||
)
|
||
|
||
reasoning_text = str(detail.get("reasoning_text") or "").strip()
|
||
if reasoning_text:
|
||
parts.append(
|
||
Panel(
|
||
Text(reasoning_text),
|
||
title=detail_labels["reasoning_title"],
|
||
border_style=reasoning_border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
output_text = str(detail.get("output_text") or "").strip()
|
||
if output_text:
|
||
parts.append(
|
||
Panel(
|
||
Text(output_text),
|
||
title=detail_labels["output_title"],
|
||
border_style=output_border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
extra_sections = detail.get("extra_sections")
|
||
if isinstance(extra_sections, list):
|
||
for section in extra_sections:
|
||
if not isinstance(section, dict):
|
||
continue
|
||
section_title = str(section.get("title") or "").strip() or "附加信息"
|
||
section_content = str(section.get("content") or "").strip()
|
||
if not section_content:
|
||
continue
|
||
parts.append(
|
||
Panel(
|
||
Text(section_content),
|
||
title=section_title,
|
||
border_style=extra_info_border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
return parts
|
||
|
||
def _build_tool_detail_cards(
|
||
self,
|
||
tool_detail_results: list[dict[str, Any]],
|
||
*,
|
||
stage_title: str,
|
||
planner_style: bool = False,
|
||
) -> list[RenderableType]:
|
||
"""将 tool monitor detail 渲染为与 Planner/Timing 平级的工具卡片。"""
|
||
|
||
detail_panel_border_style = "yellow"
|
||
sub_card_border_style = "bright_yellow"
|
||
|
||
panels: list[RenderableType] = []
|
||
for tool_result in tool_detail_results:
|
||
detail = tool_result.get("detail")
|
||
detail_dict = detail if isinstance(detail, dict) else {}
|
||
tool_name = str(tool_result.get("tool_name") or "unknown").strip() or "unknown"
|
||
tool_title = str(tool_result.get("tool_title") or "").strip() or tool_name
|
||
tool_call_id = str(tool_result.get("tool_call_id") or "").strip()
|
||
tool_args = tool_result.get("tool_args")
|
||
summary = str(tool_result.get("summary") or "").strip()
|
||
duration_ms = tool_result.get("duration_ms")
|
||
custom_card = tool_result.get("card")
|
||
|
||
parts: list[RenderableType] = []
|
||
custom_title = ""
|
||
card_border_style = detail_panel_border_style
|
||
replace_default_children = False
|
||
if isinstance(custom_card, dict):
|
||
custom_title = str(custom_card.get("title") or "").strip()
|
||
card_border_style = str(custom_card.get("border_style") or "").strip() or detail_panel_border_style
|
||
replace_default_children = bool(custom_card.get("replace_default_children", False))
|
||
custom_body_lines = self._normalize_tool_card_body_lines(
|
||
custom_card.get("body_lines", custom_card.get("content", ""))
|
||
)
|
||
if custom_body_lines:
|
||
parts.append(Text("\n".join(custom_body_lines)))
|
||
|
||
if not replace_default_children:
|
||
parts.extend(
|
||
self._build_default_tool_detail_parts(
|
||
tool_name=tool_name,
|
||
tool_call_id=tool_call_id,
|
||
tool_args=tool_args,
|
||
summary=summary,
|
||
duration_ms=duration_ms,
|
||
detail=detail_dict,
|
||
planner_style=planner_style,
|
||
)
|
||
)
|
||
|
||
if isinstance(custom_card, dict):
|
||
parts.extend(
|
||
self._build_custom_tool_sub_cards(
|
||
custom_card.get("sub_cards"),
|
||
default_border_style=sub_card_border_style,
|
||
)
|
||
)
|
||
parts.extend(
|
||
self._build_custom_tool_sub_cards(
|
||
tool_result.get("sub_cards"),
|
||
default_border_style=sub_card_border_style,
|
||
)
|
||
)
|
||
|
||
if parts:
|
||
panels.append(
|
||
Panel(
|
||
Group(*parts),
|
||
title=custom_title or f"{stage_title} · {tool_title}",
|
||
border_style=card_border_style,
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
return panels
|
||
|
||
def _log_cycle_started(self, cycle_detail: CycleDetail, round_index: int) -> None:
|
||
logger.info(
|
||
f"{self.log_prefix} MaiSaka 轮次开始: 循环编号={cycle_detail.cycle_id} "
|
||
f"回合={round_index + 1}/{self._max_internal_rounds} "
|
||
f"上下文消息数={len(self._chat_history)}"
|
||
)
|
||
|
||
def _log_cycle_completed(self, cycle_detail: CycleDetail, timer_strings: list[str]) -> None:
|
||
end_time = cycle_detail.end_time if cycle_detail.end_time is not None else cycle_detail.start_time
|
||
logger.info(
|
||
f"{self.log_prefix} MaiSaka 轮次结束: 循环编号={cycle_detail.cycle_id} "
|
||
f"总耗时={end_time - cycle_detail.start_time:.2f} 秒; "
|
||
f"阶段耗时={', '.join(timer_strings) if timer_strings else '无'}"
|
||
)
|
||
|
||
def _log_history_trimmed(self, removed_count: int, user_message_count: int) -> None:
|
||
logger.info(
|
||
f"{self.log_prefix} 已裁剪 {removed_count} 条历史消息; "
|
||
# f"剩余计入上下文的消息数={user_message_count}"
|
||
)
|
||
|
||
def _log_internal_loop_cancelled(self) -> None:
|
||
logger.info(f"{self.log_prefix} Maisaka 内部循环已取消")
|