"""发送聊天消息用例。""" from __future__ import annotations import asyncio from sqlalchemy.ext.asyncio import AsyncSession from app.ai_provider import ProviderManager, ProviderManagerFactory from app.ai_provider.types.chat import ChatChunk from app.application.agent import ( Agent, AgentConfig, AgentEvent, AgentRunContext, AgentRunRegistry, ConfirmationRegistry, ExtensionRegistry, PythonSkillCatalog, PythonSkillDefinition, activate_python_skills, compose_system_messages, default_tools, next_step_for_tool, run_agent_turn, step_event_payload, task_from_deliberative_plan, task_plan_payload, ) from app.application.tool_runtime.runtime import ToolRuntime from app.application.tool_runtime.view import RunScopedToolView from app.core.config import settings from app.core.errors import AppError, current_trace_id from app.core.logging import logger from app.core.time import utc_now from app.db.session import AsyncSessionLocal from app.models.chat import ChatMessage from app.modules.agent import DEFAULT_CHAT_AGENT from app.modules.chat.history import history_pairs from app.modules.chat.policies import validate_text_message from app.modules.goal_orchestrator import extract_session_goal, format_goal_directive from app.modules.memory.context import build_memory_context from app.modules.runtime.contract import RuntimeContract from app.modules.runtime.events import EventStore from app.modules.safety.confirmation import ConfirmationStore from app.repositories import chat as chat_repo from app.repositories import runtime as runtime_repo from app.repositories.json_utils import dumps, loads_dict from app.schemas.chat import SendMessageRequest, SendMessageResponse from app.services import runtime as runtime_service from app.services.audit import record_audit_event from app.services.characters import default_model_hint, resolve_active_character from app.services.chat import recent_chat_messages from app.services.memory import retrieve_memories from app.services.personality import build_personality_summary from app.services.runtime import TERMINAL_RUN_STATUS from app.services.safety import decide_chat_safety # 后台流式任务引用:asyncio.create_task 仅保留弱引用,需强引用防止中途被 GC。 _running_stream_tasks: set[asyncio.Task[None]] = set() # 流式增量分片阈值:每攒够 N 字符产出一条 delta 事件,配合 sse_poll_interval(0.01s) # 让前端看到逐段平滑增量。值越小流式越细、事件数越多(run_max_events 默认 1000)。 _REASONING_FLUSH_CHARS = 8 _CONTENT_FLUSH_CHARS = 8 _provider_manager: ProviderManager | None = None def _get_provider_manager() -> ProviderManager: global _provider_manager if _provider_manager is None: _provider_manager = ProviderManagerFactory.create() return _provider_manager async def drain_pending_streams() -> None: """等待所有进行中的后台流式任务完成。 ``execute`` 在创建 run 后即返回,LLM 流式在后台任务中执行;调用方在需要确保 运行终态时(测试断言、优雅停机)await 此函数。 """ if _running_stream_tasks: await asyncio.gather(*_running_stream_tasks, return_exceptions=True) def _contract_system_messages(contract: RuntimeContract) -> list[str]: messages = [*contract.system_instructions] if contract.system_prompt: messages.append(contract.system_prompt) # 卡的 system_prompt:道体增补层(§6) if contract.session_goal: messages.append(format_goal_directive(contract.session_goal)) if contract.active_goals: messages.append(contract.active_goals) # 在 session_goal 之后注入 if contract.mission: messages.append(f"核心使命:\n{contract.mission}") # 道体恒定层 if contract.lorebook_before: messages.append(contract.lorebook_before) if contract.personality_summary: messages.append(f"当前人格:\n{contract.personality_summary}") if contract.lorebook_after: messages.append(contract.lorebook_after) if contract.emotion_state: messages.append(contract.emotion_state) if contract.memory_context: messages.append(contract.memory_context) if contract.temporal_context: messages.append(contract.temporal_context) if contract.safety_guidelines: messages.append("行为边界:\n" + contract.safety_guidelines) # 道体安全(先于司命动态约束) if contract.safety_constraints: messages.append( "安全约束:\n" + "\n".join(f"- {item}" for item in contract.safety_constraints) ) # 沙箱工作目录提示:避免模型在 shell 中使用 Linux 特有路径(如 /home/user、/tmp) # 导致 macOS 上沙箱执行时 cd 失败。 messages.append( "当前运行在隔离沙箱中,所有文件操作(read_file/write_file/shell)的工作目录" "是沙箱隔离的 workspace。系统并非 Linux,/home/user、/root 等路径不存在。" "不要 cd 到这些目录;直接在 workspace 中对文件进行读写或执行命令。" ) return messages # 首轮对话自动重命名:用默认模型生成简短标题,失败静默不影响对话主流程。 _TITLE_MAX_CHARS = 40 _TITLE_STRIP_CHARS = "\"'“”‘’「」『』" async def _call_model(system_prompt: str, model_hint: str) -> str: """调用 LLM 返回纯文本。""" manager = _get_provider_manager() messages = manager.to_message_list([system_prompt], [('user', system_prompt)]) response = await manager.chat(messages, model_hint=model_hint) return response.content async def _generate_session_title(user_content: str, assistant_content: str) -> str: """用默认模型根据首轮对话内容生成简短会话标题。""" manager = _get_provider_manager() messages = manager.to_message_list( [ "你会根据一段对话生成简短的中文会话标题。" "要求:不超过 16 个字;概括对话主题;直接输出标题文字;" "不要加引号、书名号、句号,不要解释。" ], [("user", user_content), ("assistant", assistant_content)], ) response = await manager.chat(messages, model_hint=settings.default_model_hint) raw = response.content.strip().split("\n", 1)[0].strip() return raw.strip(_TITLE_STRIP_CHARS)[:_TITLE_MAX_CHARS] class SendChatMessageUseCase: """完成消息持久化与运行态记录;LLM 流式回复在后台任务中边产出边推 SSE 事件。""" def __init__( self, session: AsyncSession, *, owner_scope: str = settings.default_owner_scope, tool_runtime: ToolRuntime | None = None, confirmation_registry: ConfirmationRegistry | None = None, confirmation_store: ConfirmationStore | None = None, event_store: EventStore | None = None, agent_run_registry: AgentRunRegistry | None = None, python_skill_catalog: PythonSkillCatalog | None = None, ) -> None: self._session = session self._owner_scope = owner_scope self._tool_runtime = tool_runtime self._confirmation_registry = confirmation_registry self._confirmation_store = confirmation_store self._event_store = event_store self._agent_run_registry = agent_run_registry self._python_skill_catalog = python_skill_catalog async def execute(self, session_id: str, payload: SendMessageRequest) -> SendMessageResponse: validate_text_message(modality=payload.modality, content=payload.content) character = await resolve_active_character(self._session, owner_scope=self._owner_scope) chat_session = await chat_repo.get_session( self._session, session_id, owner_scope=self._owner_scope ) if chat_session is None or chat_session.status == "deleted": raise AppError("not_found", "会话不存在。") if payload.prior_versions: # 重答:复用原文用户消息,不新建 user 消息,避免刷新后出现重复的消息对。 recent = await chat_repo.list_recent_messages( self._session, chat_session.id, limit=10, ) existing_user = None for msg in reversed(recent): if msg.role == "user" and msg.deleted_at is None: existing_user = msg break if existing_user is None: raise AppError("not_found", "要重答的原始用户消息不存在。") user_message = existing_user else: user_message = ChatMessage( character_id=character.id, session_id=chat_session.id, role="user", content=payload.content, modality="text", metadata_json=dumps({}), sensitivity="low", ) self._session.add(user_message) await self._session.flush() model_hint = payload.model_hint or default_model_hint(character) session_goal = extract_session_goal(payload.content) run = await runtime_service.create_run( self._session, character_id=character.id, session_id=chat_session.id, model_hint=model_hint, goal=session_goal or "回复用户消息", ) user_message.run_id = run.id await runtime_service.append_event( self._session, run_id=run.id, character_id=character.id, event_type="run.started", payload={"run_id": run.id, "session_id": chat_session.id}, ) # 先提交 run + run.started:HTTP 响应随后返回 run_id,前端据此建立 SSE; # 后台流式在独立 session 中边产出边提交,前端 SSE(DB 轮询)即可实时读到增量。 await self._session.commit() task = asyncio.create_task( self._stream_assistant_response( run_id=run.id, character_id=character.id, chat_session_id=chat_session.id, payload=payload, ) ) _running_stream_tasks.add(task) task.add_done_callback(_running_stream_tasks.discard) from app.core.config import settings as _settings return SendMessageResponse( message_id=user_message.id, assistant_message_id=None, run_id=run.id, session_id=chat_session.id, character_id=character.id, status=run.status, stream_url=f"{_settings.api_prefix}/runs/{run.id}/stream", ) async def _stream_assistant_response( self, *, run_id: str, character_id: str, chat_session_id: str, payload: SendMessageRequest, ) -> None: """后台流式:独立 session 完成 LLM 调用与事件推送,异常定稿为 run.failed。""" try: async with AsyncSessionLocal() as session: await self._produce_assistant_stream( session, run_id=run_id, character_id=character_id, chat_session_id=chat_session_id, payload=payload, ) except TimeoutError: await self._fail_stream( run_id=run_id, character_id=character_id, code="run_timeout", message="运行超时。", ) except Exception: logger.exception("chat_stream_failed", run_id=run_id) await self._fail_stream( run_id=run_id, character_id=character_id, code="run_failed", message="运行失败,请稍后重试。", ) async def _produce_assistant_stream( self, session: AsyncSession, *, run_id: str, character_id: str, chat_session_id: str, payload: SendMessageRequest, ) -> None: character = await resolve_active_character(session, owner_scope=self._owner_scope) model_hint = payload.model_hint or default_model_hint(character) run = await runtime_repo.get_run(session, run_id) if run is None or run.status in TERMINAL_RUN_STATUS: return session_goal = run.goal or "" # 重答场景(prior_versions):在构建上下文前软删除被替换的旧 assistant 消息, # 保留原文用户消息不动——用户消息已在 execute() 中复用,不新建、不删除。 if payload.prior_versions: latest = await chat_repo.list_recent_messages( session, chat_session_id, limit=5, ) # latest 按 created_at ASC 排列且仅含未删除消息,从末尾往前找最后一个 assistant for i in range(len(latest) - 1, -1, -1): if latest[i].role == "assistant" and latest[i].deleted_at is None: latest[i].deleted_at = utc_now() break await session.commit() safety = await decide_chat_safety( session, character_id=character_id, run_id=run_id, ) personality = await build_personality_summary(session, character) memories = await retrieve_memories( session, character_id, limit=settings.memory_injection_limit, ) memory_context = build_memory_context(memories) # 息壤推送:将检索到的记忆以结构化数据推送到前端 SSE 事件流 if memories.memories: memory_items = [] for m in memories.memories: memory_items.append( { "id": m.id, "title": m.title, "content": m.content, "source": m.source, "confidence": m.confidence, "importance": m.importance, "type": m.type, } ) await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="memory.retrieved", payload={"memories": memory_items}, ) await session.commit() recent_messages = await recent_chat_messages(session, chat_session_id) trace_id = current_trace_id() # 辰正 temporal_context(设计 §8.1):对话入口算时态快照 + 发 time.* 事件到当前 run。 # 降级原则(§12):任何失败 → temporal_context=None,不注入,不阻塞对话。 temporal_context: str | None = None if self._event_store is not None: try: from app.modules.time_sense.modulation import ModulationInput from app.services.time_sense import compute_live_snapshot time_result = await compute_live_snapshot( session, self._event_store, character_id, ModulationInput(), run_id=run_id, owner_scope=self._owner_scope, ) temporal_context = time_result.context_text or None except Exception: # noqa: BLE001 logger.warning("temporal_context 计算失败,已跳过", run_id=run_id) # 灵核情绪状态注入(设计 §11.5):拉模式 compute_emotion_prompt。 # 降级原则(§12):任何失败 → emotion_state=None,不注入,不阻塞对话。 emotion_state: str | None = None try: from app.services.personality_emotion import compute_emotion_prompt emotion_state = await compute_emotion_prompt(session, character_id) except Exception: # noqa: BLE001 logger.warning("emotion_state 计算失败,已跳过", run_id=run_id) # 世谱(Lorebook)注入:扫对话历史匹配命中条目,注入 before/after(设计 §7) # 降级原则:任何失败 → 不注入,不阻塞对话。 lorebook_before: str | None = None lorebook_after: str | None = None try: from app.services.lorebook import build_lorebook_injection scan_parts = [payload.content] scan_parts.extend( m.content for m in recent_messages[-8:] if m.role in ("user", "assistant") ) lorebook_before, lorebook_after = await build_lorebook_injection( session, character_id, self._owner_scope, "\n".join(scan_parts), ) except Exception: # noqa: BLE001 logger.warning("世谱注入失败,已跳过", run_id=run_id) profile_data = loads_dict(character.profile_json or "{}") contract = RuntimeContract( character_id=character_id, session_id=chat_session_id, run_id=run_id, model_hint=model_hint, agent=DEFAULT_CHAT_AGENT, system_instructions=[], system_prompt=profile_data.get("system_prompt") or None, session_goal=session_goal or None, personality_summary=personality.text, mission=character.mission, safety_guidelines=character.safety_guidelines, lorebook_before=lorebook_before, lorebook_after=lorebook_after, emotion_state=emotion_state, memory_context=memory_context, temporal_context=temporal_context, recent_messages=recent_messages, safety_constraints=safety.constraints, trace_id=trace_id, ) # 道体完整性校验(§6):确认恒定层未被动态注入在数据层覆盖 from app.modules.runtime.integrity import validate_daoti_integrity validate_daoti_integrity( contract, mission=character.mission, safety_guidelines=character.safety_guidelines, ) # —— 活跃目标注入 —— try: from app.services.governance import load_active_goals_context active_goals = await load_active_goals_context(character.id) if active_goals: contract.active_goals = active_goals except Exception: logger.warning("活跃目标加载失败,跳过注入") await record_audit_event( session, action="model.request.started", character_id=character_id, run_id=run_id, actor="system", decision="auto", input_summary=model_hint, resource_type="run", resource_id=run_id, ) system_messages = compose_system_messages( _contract_system_messages(contract), contract.agent, [], ) # ── 百工技能执行引擎:匹配 → prompt 注入 → 工具绑定注册 ── from app.application.skill_executor import SkillExecutor tool_view = RunScopedToolView(self._tool_runtime) if self._tool_runtime is not None else None skill_exec = SkillExecutor( tool_view=tool_view, event_store=self._event_store, ) try: await skill_exec.match_and_activate( payload.content, system_messages, run_id=run_id, character_id=character_id, skill_refs=contract.agent.skill_refs if contract.agent else (), ) except Exception: # noqa: BLE001 logger.warning("skill_executor 匹配失败,已跳过") agent_task = None completed_plan_step_ids: set[str] = set() # ── 洛书 Deliberative 规划 ── if settings.enable_deliberative_planning: from app.application.planning import ( build_planning_prompt, format_plan_for_prompt, parse_plan, should_plan, ) if should_plan(payload.content): try: planner_prompt = build_planning_prompt( payload.content, memory_context=memory_context[:300] if memory_context else '', ) planner_raw = await _call_model(planner_prompt, model_hint) plan = parse_plan(planner_raw) if plan: agent_task = task_from_deliberative_plan( plan, run_id=run_id, agent_name=contract.agent.name if contract.agent else "默认 Agent", objective=payload.content, ) await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="plan.created", source="planner", payload=task_plan_payload(agent_task), ) await session.commit() plan_text = format_plan_for_prompt(plan) system_messages.append( "【当前计划】\n请按以下计划逐步执行。" "每一步完成后标记完成,再进入下一步。" "全部完成后回复总结。\n\n" + plan_text ) logger.info(f"洛书生成 deliberative plan: {plan.title} ({len(plan.steps)} 步)") except Exception: logger.warning('deliberative 规划生成失败,回退 ReAct') python_skills: tuple[PythonSkillDefinition, ...] = () if self._python_skill_catalog is not None: python_skills = self._python_skill_catalog.match( payload.content, skill_refs=contract.agent.skill_refs if contract.agent else (), metadata={ "run_id": run_id, "session_id": chat_session_id, "character_id": character_id, "model_hint": model_hint, }, ) for skill in python_skills: if skill.instructions: system_messages.append(f"【Python Skill:{skill.name}】\n{skill.instructions}") manager = _get_provider_manager() messages = manager.to_message_list( system_messages, history_pairs(recent_messages), ) reasoning_buf = "" content_buf = "" full_content = "" had_reasoning = False reasoning_finalized = False last_model = model_hint async def flush_reasoning(*, force: bool = False) -> None: nonlocal reasoning_buf while reasoning_buf and (len(reasoning_buf) >= _REASONING_FLUSH_CHARS or force): cut = ( _REASONING_FLUSH_CHARS if len(reasoning_buf) >= _REASONING_FLUSH_CHARS else len(reasoning_buf) ) delta, reasoning_buf = reasoning_buf[:cut], reasoning_buf[cut:] await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="reasoning.delta", source="model", payload={"delta": delta}, ) await session.commit() async def flush_content(*, force: bool = False) -> None: nonlocal content_buf while content_buf and (len(content_buf) >= _CONTENT_FLUSH_CHARS or force): cut = ( _CONTENT_FLUSH_CHARS if len(content_buf) >= _CONTENT_FLUSH_CHARS else len(content_buf) ) delta, content_buf = content_buf[:cut], content_buf[cut:] await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="message.delta", source="model", payload={"delta": delta}, ) await session.commit() async def finalize_reasoning() -> None: """收尾思考阶段:冲刷残余 reasoning 并推送 reasoning.completed。 在第一段正文出现前调用,确保思考摘要完整先于正文;流结束兜底再调一次。 多轮 Agent 场景下,每轮模型输出的 content 出现前都会触发,已被 reasoning_finalized 标记短路——即仅第一段 reasoning 完整 flush 后发 completed,后续轮次的 reasoning 仍经 on_chunk 实时 flush,但不再发 completed(避免事件流断裂)。 思考模式关闭时(payload.thinking=False)不处理任何 reasoning 事件, 防止模型仍产出 reasoning_content 导致前端 ThoughtBlock 误展示。 """ nonlocal reasoning_finalized if reasoning_finalized or not payload.thinking: return reasoning_finalized = True await flush_reasoning(force=True) if had_reasoning: await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="reasoning.completed", source="model", payload={}, ) await session.commit() async def on_chunk(chunk: ChatChunk) -> None: """把 Agent 流式 chunk 喂给现有 buf/flush/finalize 逻辑,事件行为与单轮流式等价。""" nonlocal reasoning_buf, content_buf, full_content, had_reasoning # 思考模式关闭时不处理 reasoning chunk,避免前端 ThoughtBlock 错误展示 if payload.thinking and chunk.delta_reasoning: had_reasoning = True reasoning_buf += chunk.delta_reasoning await flush_reasoning() if chunk.delta_content: # 正文开始前先收尾思考,保证思考摘要完整先于正文 await finalize_reasoning() content_buf += chunk.delta_content full_content += chunk.delta_content await flush_content() # 收集工具调用信息,最终存入消息 metadata 供前端持久化展示 tool_calls: list[dict[str, object]] = [] _started_tool_calls: dict[str, dict[str, object]] = {} _plan_steps_by_tool_call = {} async def _reset_turn_state() -> None: """每轮 Agent 开始前重置 reasoning 标记(多轮场景支持)。 Agent loop 首轮产出 reasoning → content → tool_calls,次轮可能再产出 reasoning。 若不重置,reasoning_finalized=True 导致 reasoning.completed 无法再发出。 """ nonlocal had_reasoning, reasoning_finalized had_reasoning = False reasoning_finalized = False async def on_tool_event(event_type: str, payload_dict: dict[str, object]) -> None: """工具生命周期事件:作为 agent 事件落库 + 收集 tool_calls 入 metadata。""" await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type=event_type, source="agent", payload=payload_dict, ) await session.commit() # 收集工具调用信息供前端持久化展示 if event_type == "tool.started": tc_id = str(payload_dict.get("tool_call_id", "")) tool_name = str(payload_dict.get("tool_name", "")) if agent_task is not None: step = next_step_for_tool(agent_task, tool_name, completed_plan_step_ids) if step is not None: _plan_steps_by_tool_call[tc_id] = step await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="plan.step.started", source="planner", payload=step_event_payload(agent_task, step, status="running"), ) await session.commit() _started_tool_calls[tc_id] = { "tool_name": str(payload_dict.get("tool_name", "")), "tool_call_id": tc_id, "args": str(payload_dict.get("args", "")), "status": "running", } elif event_type in ("tool.completed", "tool.failed"): tc_id = str(payload_dict.get("tool_call_id", "")) entry = _started_tool_calls.pop(tc_id, None) step = _plan_steps_by_tool_call.pop(tc_id, None) if step is not None and agent_task is not None: tool_status = str(payload_dict.get("status", "error")) step_status = "completed" if tool_status == "success" else "failed" if step_status == "completed": completed_plan_step_ids.add(step.id) await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type=f"plan.step.{step_status}", source="planner", payload=step_event_payload( agent_task, step, status=step_status, result_summary=str(payload_dict.get("result", ""))[:500] or None, ), ) await session.commit() if entry is not None: entry["status"] = str(payload_dict.get("status", "error")) entry["result"] = str(payload_dict.get("result", "")) tool_calls.append(entry) extensions = ExtensionRegistry() python_skill_activation = await activate_python_skills(extensions, python_skills) if python_skills: await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="skill.activated", source="agent", payload={"skills": [skill.name for skill in python_skills], "kind": "python"}, ) await session.commit() async def persist_agent_event(event: AgentEvent, _run_context: AgentRunContext) -> None: await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type=event.type, source="agent", payload=event.payload, ) await session.commit() extensions.add_event_listener(persist_agent_event) on_confirm_timeout = None if self._confirmation_store is not None: confirmation_store = self._confirmation_store async def on_confirm_timeout(confirmation_id: str) -> None: await confirmation_store.mark_timed_out(confirmation_id) agent = Agent( AgentConfig( tools=tuple( default_tools( tool_view.specs_by_name(), allowed_tools=contract.agent.tool_policy.allowed_tools if contract.agent else (), ) if tool_view is not None else () ), tool_runtime=tool_view, model_hint=(contract.agent.model_hint or model_hint) if contract.agent else model_hint, thinking_enabled=payload.thinking, reasoning_effort=payload.reasoning_effort, max_iterations=contract.agent.execution_policy.max_iterations if contract.agent else settings.agent_max_iterations, confirmation_registry=self._confirmation_registry, confirmation_timeout=settings.confirmation_timeout_seconds, on_turn=_reset_turn_state, on_chunk=on_chunk, on_tool_event=on_tool_event, on_confirm_timeout=on_confirm_timeout, ), messages=messages, run_context=AgentRunContext( run_id=run_id, session_id=chat_session_id, character_id=character_id, trace_id=trace_id, ), extensions=extensions, # 保留模块级符号作为注入点,现有测试与外部集成无需重写。 runner=run_agent_turn, ) if self._agent_run_registry is not None: await self._agent_run_registry.register(run_id, agent) try: run = await runtime_repo.get_run(session, run_id) if run is None or run.status in TERMINAL_RUN_STATUS: return async with asyncio.timeout(settings.run_timeout_seconds): agent_result = await agent.continue_run() finally: if self._agent_run_registry is not None: await self._agent_run_registry.unregister(run_id, agent) await python_skill_activation.close() # 流结束兜底:模型可能只输出思考无正文,或残余 buffer 未达阈值。 await finalize_reasoning() await flush_content(force=True) # 取消可能发生在模型返回与持久化之间;终态 Run 不再写助手消息或覆盖状态。 run = await runtime_repo.get_run(session, run_id) if run is None or run.status in TERMINAL_RUN_STATUS: return # 以 agent_result.content 作为权威最终回复正文持久化(应与 full_content 一致; # 不一致时以 agent 循环产出为准,因后者覆盖多轮模型最终输出)。 final_content = agent_result.content or full_content version_ts = utc_now().isoformat() # 构造版本历史:prior_versions 前置 + 当前回复作为最新版本 prior = payload.prior_versions or [] all_versions = [*prior, {"content": final_content, "created_at": version_ts}] active_idx = len(all_versions) - 1 # 始终最新 assistant = ChatMessage( character_id=character_id, session_id=chat_session_id, role="assistant", content=final_content, modality="text", run_id=run_id, metadata_json=dumps( { "model_hint": model_hint, "model": last_model, "tool_calls": tool_calls, } ), sensitivity="low", versions_json=dumps(all_versions), active_version_idx=active_idx, ) session.add(assistant) await session.flush() await record_audit_event( session, action="model.response.completed", character_id=character_id, run_id=run_id, actor="model", decision="auto", output_summary="模型响应已完成。", resource_type="message", resource_id=assistant.id, ) await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="message.completed", source="model", payload={"message_id": assistant.id, "content": final_content}, ) run = await runtime_repo.get_run(session, run_id) if run is not None and run.status not in TERMINAL_RUN_STATUS: await runtime_service.complete_run(session, run) await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="run.completed", payload={"run_id": run_id}, ) await session.commit() # 首轮对话完成后,用默认模型自动生成会话标题(失败不影响主流程) await self._maybe_rename_session( session, chat_session_id=chat_session_id, user_content=payload.content, assistant_content=final_content, ) # 每轮对话后检查大周天触发条件(失败不影响主流程) try: from app.application.big_cycle import maybe_trigger_big_cycle await maybe_trigger_big_cycle(character_id, trigger="session_turn") except Exception: # noqa: BLE001 logger.warning("大周天触发检查失败,已跳过") # 小周天每轮记忆候选 + 返照反思(后台任务,失败不影响主流程) try: from app.application.reflection import start_reflection_after_turn start_reflection_after_turn( character_id=character_id, session_id=chat_session_id, user_content=payload.content, assistant_content=final_content, ) except Exception: # noqa: BLE001 logger.warning("小周天反思触发失败,已跳过") # —— 会话目标自动持久化 —— try: from app.db.session import AsyncSessionLocal as GoalSession from app.services.goal_persist import auto_persist_session_goal async with GoalSession() as goal_session: await auto_persist_session_goal(goal_session, character.id, payload.content) await goal_session.commit() except Exception: logger.warning("会话目标自动持久化失败,跳过") async def _maybe_rename_session( self, session: AsyncSession, *, chat_session_id: str, user_content: str, assistant_content: str, ) -> None: """首轮对话完成后用 LLM 生成会话标题;已有标题或非首轮则跳过,失败静默。""" try: existing = await chat_repo.get_session(session, chat_session_id) if existing is None or existing.title: return assistant_count = await chat_repo.count_messages_by_role( session, chat_session_id, role="assistant" ) if assistant_count != 1: return title = await _generate_session_title(user_content, assistant_content) if not title: return await chat_repo.update_session_title(session, chat_session_id, title) await session.commit() logger.info("session_auto_renamed", session_id=chat_session_id, title=title) except Exception: logger.warning("session_title_generate_failed", session_id=chat_session_id) async def _fail_stream( self, *, run_id: str, character_id: str, code: str, message: str, ) -> None: async with AsyncSessionLocal() as session: run = await runtime_repo.get_run(session, run_id) if run is None or run.status in TERMINAL_RUN_STATUS: return await runtime_service.fail_run(session, run, code=code, message=message) await runtime_service.append_event( session, run_id=run_id, character_id=character_id, event_type="run.failed", payload={"code": code, "message": message}, ) await session.commit()