""" Jarvis Agent 服务层 负责 LangGraph Agent 的调用、流式输出、对话历史管理 """ import json import uuid import logging from datetime import UTC, datetime from time import perf_counter from typing import Any, AsyncGenerator import asyncio from openai import BadRequestError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from langchain_core.messages import HumanMessage, AIMessage, SystemMessage from app.database import async_session from app.logging_utils import summarize_llm_config from app.models.conversation import Conversation, Message from app.models.user import User from app.agents.graph import get_agent_graph from app.agents.context import set_current_user, clear_current_user from app.agents.learning.jobs import schedule_retrospective_job from app.agents.learning.retrospector import build_session_retrospective from app.agents.learning.session_search import SessionRetrospectiveSearch, summarize_retrospective from app.agents.orchestration.task_graph import build_bounded_task_graph from app.agents.learning.store import append_retrospective_attachment from app.agents.schemas.orchestration import ( RuntimeRequestContext, assess_parallel_worthiness, render_runtime_request_context_summary, ) from app.agents.schemas.skills import SkillActivationRecord from app.agents.skills.registry import get_skill_registry from app.agents.skills.retriever import shortlist_skills_for_request from app.services import memory_service from app.services.brain_service import BrainService from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities from app.services.rollback_controller import RollbackController from app.services.runtime_observability import build_runtime_observability_report from app.agents.tools.time_reasoning import extract_reference_datetime from app.agents.state import initial_state logger = logging.getLogger(__name__) MEMORY_SECTION_HEADERS = ( "【用户记忆】", "【之前对话摘要】", "【知识大脑】", ) MEMORY_INLINE_HEADERS = {"[关于你的记忆]"} def _split_memory_context_sections(memory_context: str | None) -> dict[str, str]: text = (memory_context or "").strip() if not text: return {} sections: dict[str, str] = {} current_header: str | None = None current_lines: list[str] = [] for line in text.splitlines(): stripped = line.strip() if stripped in MEMORY_SECTION_HEADERS: if current_header and current_lines: sections[current_header] = "\n".join(current_lines).strip() current_header = stripped current_lines = [stripped] continue if current_header: current_lines.append(line) if current_header and current_lines: sections[current_header] = "\n".join(current_lines).strip() return sections def _derive_role_memory_contexts(memory_context: str | None) -> dict[str, str | None]: sections = _split_memory_context_sections(memory_context) user_memory = sections.get("【用户记忆】") summaries = sections.get("【之前对话摘要】") knowledge = sections.get("【知识大脑】") def _join_parts(*parts: str | None) -> str | None: values = [part for part in parts if part] return "\n\n".join(values) if values else None return { "schedule_context_summary": _join_parts(user_memory, summaries), "knowledge_context": knowledge, "analysis_report": _join_parts(summaries, knowledge), } def _extract_memory_highlights(memory_context: str | None, *, limit: int = 5) -> list[str]: text = (memory_context or "").strip() if not text: return [] highlights: list[str] = [] for raw_line in text.splitlines(): line = raw_line.strip() if not line or line in MEMORY_SECTION_HEADERS or line in MEMORY_INLINE_HEADERS: continue if line.startswith("-"): normalized = line[1:].strip() else: normalized = line if normalized: highlights.append(normalized) if len(highlights) >= limit: break return highlights def _summarize_retrospective(retrospective: Any) -> str: summary = str(getattr(retrospective, "summary", "") or "").strip() task_type = str(getattr(retrospective, "task_type", "") or "").strip() execution_mode = str(getattr(retrospective, "execution_mode", "") or "").strip() outcome = str(getattr(retrospective, "outcome", "") or "").strip() parts = [summary[:80] or task_type or "历史复盘"] if execution_mode: parts.append(f"mode={execution_mode}") if outcome: parts.append(f"outcome={outcome}") return ";".join(parts) def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None) -> bool: capabilities = resolve_provider_capabilities(user_llm_config) error_text = str(error).lower() markers = [ "invalid chat setting", "invalid params", "stream", "streaming", "unsupported", "bad_request_error", "http 400", "error code: 400", ] if isinstance(error, BadRequestError): return getattr(capabilities, "provider", None) not in {"openai", "claude"} and any( marker in error_text for marker in markers ) return any(marker in error_text for marker in markers) def _coerce_event_text(content: Any) -> str: if isinstance(content, str): return content if isinstance(content, list): parts: list[str] = [] for item in content: if isinstance(item, str): parts.append(item) elif isinstance(item, dict): text = item.get("text") if isinstance(text, str): parts.append(text) return "".join(parts) return str(content) if content else "" _CONTINUITY_STATE_VERSION = 1 _CONTINUITY_SNAPSHOT_FIELDS = ( "turn_context", "routing_decision", "continuity_state", "pending_action", "last_completed_action", "clarification_context", "tool_outcomes", "pending_tasks", "completed_tasks", "created_entities", "current_agent", "next_step", "agent_trace", "agent_id", "parent_agent_id", "root_agent_id", "collaboration_depth", "thread_id", "last_message_id", "message_sequence", "spawned_agent_ids", "current_sub_commander", "active_sub_commanders", "sub_commander_trace", "event_trace", "message_trace", "active_tasks", "task_results", "task_hierarchy", "verification_status", "verification_summary", "verification_evidence", "isolation_mode", "isolation_id", "isolation_workspace_path", "isolation_parent_conversation_id", "isolation_metadata", "input_tokens", "output_tokens", "estimated_cost", "budget_warning", "cost_by_agent", "cost_thresholds", "budget_state", "collaboration_budget_history", "current_phase", "phase_history", "current_checkpoint", "checkpoint_history", ) def _normalize_legacy_turn_context(turn_context: Any, current_agent: Any) -> dict[str, Any] | None: if not isinstance(turn_context, dict): return None normalized = dict(turn_context) active_agent = normalized.pop("active_agent", None) active_sub_flow = normalized.pop("active_sub_flow", None) if isinstance(active_agent, str) and active_agent and "active_agent" not in normalized: normalized["active_agent"] = active_agent if ( isinstance(active_sub_flow, str) and active_sub_flow and "active_sub_commander" not in normalized ): normalized["active_sub_commander"] = active_sub_flow if not normalized.get("active_agent") and isinstance(current_agent, str) and current_agent: normalized["active_agent"] = current_agent return normalized or None def _normalize_legacy_pending_action(pending_action: Any) -> dict[str, Any] | None: if not isinstance(pending_action, dict): return None normalized = dict(pending_action) legacy_action_type = normalized.pop("action_type", None) if legacy_action_type and "type" not in normalized: normalized["type"] = legacy_action_type legacy_agent = normalized.pop("agent", None) legacy_sub_flow = normalized.pop("sub_flow", None) if legacy_agent and "owner_agent" not in normalized: normalized["owner_agent"] = legacy_agent if legacy_sub_flow and "owner_sub_commander" not in normalized: normalized["owner_sub_commander"] = legacy_sub_flow legacy_status = normalized.get("status") if legacy_status == "awaiting_confirmation": normalized["status"] = "pending" elif legacy_status == "awaiting_clarification": normalized["status"] = "blocked_on_clarification" return normalized or None def _normalize_legacy_clarification_context( clarification_context: Any, pending_action: dict[str, Any] | None, current_agent: Any, ) -> dict[str, Any] | None: if not isinstance(clarification_context, dict): return None normalized = dict(clarification_context) active_agent = normalized.pop("active_agent", None) sub_flow = normalized.pop("sub_flow", None) awaiting_user_input = normalized.pop("awaiting_user_input", None) if isinstance(active_agent, str) and active_agent and "owning_agent" not in normalized: normalized["owning_agent"] = active_agent if isinstance(sub_flow, str) and sub_flow and "owning_sub_commander" not in normalized: normalized["owning_sub_commander"] = sub_flow if "target_action" not in normalized: target_action = None if pending_action: pending_type = pending_action.get("type") if isinstance(pending_type, str) and pending_type and pending_type != "clarification": target_action = pending_type if target_action is None and isinstance(sub_flow, str) and sub_flow.startswith("create_"): target_action = sub_flow if target_action: normalized["target_action"] = target_action if not normalized.get("owning_agent") and isinstance(current_agent, str) and current_agent: normalized["owning_agent"] = current_agent if awaiting_user_input is True and "status" not in normalized: normalized["status"] = "pending" return normalized or None def _normalize_legacy_continuity_state( continuity_state: Any, clarification_context: dict[str, Any] | None, ) -> dict[str, Any] | None: if not isinstance(continuity_state, dict): return None normalized = dict(continuity_state) normalized.pop("active_agent", None) normalized.pop("active_sub_flow", None) legacy_status = normalized.get("status") if legacy_status == "awaiting_clarification": normalized["status"] = "fresh" if clarification_context and "mode" not in normalized: normalized["mode"] = "resume_after_clarification" return normalized or None def _normalize_continuity_snapshot(state: dict[str, Any]) -> dict[str, Any]: normalized = dict(state) current_agent = normalized.get("current_agent") pending_action = _normalize_legacy_pending_action(normalized.get("pending_action")) clarification_context = _normalize_legacy_clarification_context( normalized.get("clarification_context"), pending_action, current_agent, ) continuity_state = _normalize_legacy_continuity_state( normalized.get("continuity_state"), clarification_context, ) turn_context = _normalize_legacy_turn_context(normalized.get("turn_context"), current_agent) if pending_action is not None: normalized["pending_action"] = pending_action if clarification_context is not None: normalized["clarification_context"] = clarification_context if continuity_state is not None: normalized["continuity_state"] = continuity_state if turn_context is not None: normalized["turn_context"] = turn_context return normalized def _build_continuity_snapshot(state: dict[str, Any]) -> dict[str, Any] | None: normalized_state = _normalize_continuity_snapshot(state) snapshot = { field: normalized_state.get(field) for field in _CONTINUITY_SNAPSHOT_FIELDS if normalized_state.get(field) is not None } if not snapshot: return None return { "version": _CONTINUITY_STATE_VERSION, "state": snapshot, } def _extract_continuity_snapshot(payload: Any) -> dict[str, Any] | None: if isinstance(payload, list): for item in payload: snapshot = _extract_continuity_snapshot(item) if snapshot: return snapshot return None if not isinstance(payload, dict): return None if payload.get("kind") != "agent_continuity_state": return None if payload.get("version") != _CONTINUITY_STATE_VERSION: return None state = payload.get("state") if isinstance(state, dict): return _normalize_continuity_snapshot(state) return None class AgentService: """对话 Agent 服务""" def __init__(self, db: AsyncSession): self.db = db async def _try_auto_summarize_background(self, user_id: str, conversation_id: str) -> None: async with async_session() as session: await memory_service.try_auto_summarize(session, user_id, conversation_id) # ———— M.4: 主动记忆提取 ———— async def _extract_memories_background(self, user_id: str, conversation_id: str) -> None: """Background task to extract memories from conversation after response.""" from app.services.memory.memory_extractor import MemoryExtractor from sqlalchemy import select from app.models.conversation import Message try: async with async_session() as db: # Load last 10 messages from conversation result = await db.execute( select(Message) .where(Message.conversation_id == conversation_id) .order_by(Message.created_at.desc()) .limit(10) ) messages = list(result.scalars().all()) if len(messages) < 2: return extractor = MemoryExtractor() new_memories = await extractor.extract_from_conversation( db, user_id, conversation_id, messages ) if new_memories: await extractor.save_memories(db, user_id, conversation_id, new_memories) logger.info( f"[MemoryExtractor] Extracted {len(new_memories)} new memories from conversation {conversation_id}" ) except Exception as e: logger.exception(f"[MemoryExtractor] Extraction failed: {e}") def _build_progress_event( self, stage: str, label: str, *, agent: str | None = None, tool_name: str | None = None, step: str | None = None, steps: list[str] | None = None, ) -> dict[str, Any]: return { "type": "progress", "stage": stage, "label": label, "agent": agent, "tool_name": tool_name, "step": step, "steps": steps or [], } def _build_current_datetime_context(self) -> tuple[str, dict[str, str]]: now_utc = datetime.now(UTC) reference = { "current_time_iso": now_utc.isoformat(), "current_date_iso": now_utc.date().isoformat(), } context = ( "【当前时间】\n" f"- current_time_utc: {reference['current_time_iso']}\n" f"- current_date_utc: {reference['current_date_iso']}\n" "说明:解析'今天/明天/后天/本周/下周'等相对时间时,请以 current_time_utc 为准。" ) return context, reference def build_skill_context(self, skill_names: list[str]) -> dict: """构建 Skills 上下文 Args: skill_names: Skill 名称列表 Returns: 包含 skills 上下文的字典 """ registry = get_skill_registry() merged_context = registry.get_skill_context(skill_names) return { "skills_context": merged_context, "skills_metadata": { "skills": skill_names, "count": len(skill_names), }, } async def _get_user_llm_config( self, user_id: str, model_name: str | None = None ) -> dict | None: """获取用户的 LLM 模型配置""" user = await self.db.get(User, user_id) if not user or not user.llm_config: return None llm_config = user.llm_config if model_name: models = llm_config.get("chat", []) for m in models: if m.get("name") == model_name: return m return None chat_models = llm_config.get("chat", []) for m in chat_models: if m.get("enabled"): return m return None async def _load_continuity_snapshot(self, conversation: Conversation) -> dict[str, Any] | None: snapshot = _extract_continuity_snapshot(getattr(conversation, "agent_state", None)) if snapshot: return snapshot result = await self.db.execute( select(Message) .where(Message.conversation_id == conversation.id, Message.role == "assistant") .order_by(Message.created_at.desc()) ) for message in result.scalars(): snapshot = _extract_continuity_snapshot(message.attachments) if snapshot: return snapshot return None async def _build_agent_state( self, *, request_id: str, user_id: str, conversation: Conversation, raw_user_query: str, full_message: str, memory_context: str | None, current_datetime_context: str, current_datetime_reference: dict[str, str], user_llm_config: dict | None, runtime_request_context: RuntimeRequestContext, recalled_retrospectives: list[dict[str, Any]], skill_shortlist: list[dict[str, Any]], ) -> dict[str, Any]: state = initial_state(user_id, conversation.id) runtime_summary = render_runtime_request_context_summary(runtime_request_context) state.update( { "messages": [ SystemMessage(content=runtime_summary), HumanMessage(content=full_message), ], "memory_context": memory_context, "current_datetime_context": current_datetime_context, "current_datetime_reference": current_datetime_reference, "user_llm_config": user_llm_config, } ) previous_snapshot = await self._load_continuity_snapshot(conversation) if previous_snapshot: state.update(previous_snapshot) state["messages"] = [ SystemMessage(content=runtime_summary), HumanMessage(content=full_message), ] state.update( { "runtime_request_context": runtime_request_context.model_dump(mode="json"), "task_graph": ( runtime_request_context.task_graph.model_dump(mode="json") if runtime_request_context.task_graph is not None else None ), "feature_flags": RollbackController().snapshot_flags(), "recalled_retrospectives": recalled_retrospectives, "retrospective_shortlist": recalled_retrospectives, "skill_shortlist": skill_shortlist, "skill_activation_records": [ SkillActivationRecord( skill_name=item.get("skill_name"), source=item.get("source", "runtime"), source_id=item.get("source_id"), status=item.get("status", "active"), injection_mode=item.get("injection_mode", "metadata_only"), matched_terms=item.get("matched_terms", []), rationale=item.get("rationale"), ).model_dump(mode="json") for item in skill_shortlist if item.get("skill_name") ], "parallel_worthiness": runtime_request_context.parallel_worthiness.model_dump( mode="json" ), } ) return state async def _build_runtime_request_context( self, *, request_id: str, user_id: str, conversation: Conversation, user_query: str, memory_context: str | None, ) -> tuple[RuntimeRequestContext, list[dict[str, Any]], list[dict[str, Any]]]: started_at = perf_counter() retrospectives_started = perf_counter() recent_retrospectives = await SessionRetrospectiveSearch(self.db).shortlist( user_id=user_id, query_text=user_query, conversation_id=conversation.id, limit=3, ) retrospective_ms = (perf_counter() - retrospectives_started) * 1000 feature_flags = RollbackController().snapshot_flags() shortlist_started = perf_counter() skill_shortlist = await shortlist_skills_for_request( self.db, user_id=user_id, user_query=user_query, memory_context=memory_context, retrospectives=[item.model_dump(mode="json") for item in recent_retrospectives], include_learned=feature_flags["ENABLE_LEARNED_SKILL_LOADING"], limit=4, ) skill_shortlist_ms = (perf_counter() - shortlist_started) * 1000 parallel_worthiness = assess_parallel_worthiness( user_query, retrospective_count=len(recent_retrospectives), skill_count=len(skill_shortlist), ) recommended_runtime_mode = ( "collaboration" if parallel_worthiness.preferred_mode != "direct" else "direct" ) task_graph = ( build_bounded_task_graph( query_text=user_query, parallel_worthiness=parallel_worthiness, ) if feature_flags["ENABLE_PARALLEL_TASK_GRAPH"] else None ) runtime_request_context = RuntimeRequestContext( request_id=request_id, session_id=conversation.id, conversation_id=conversation.id, user_id=user_id, query_text=user_query, raw_user_query=user_query, recalled_memories=_extract_memory_highlights(memory_context), recalled_retrospectives=[ summarize_retrospective(retrospective) for retrospective in recent_retrospectives ], shortlisted_skills=[entry.skill_name for entry in skill_shortlist], skill_shortlist=skill_shortlist, current_agent_role="master", execution_mode=recommended_runtime_mode, conversation_state_ref=conversation.id, parallel_worthiness=parallel_worthiness, task_graph=task_graph, recommended_runtime_mode=recommended_runtime_mode, assembly_metrics={ "retrospective_ms": round(retrospective_ms, 3), "skill_shortlist_ms": round(skill_shortlist_ms, 3), "total_ms": round((perf_counter() - started_at) * 1000, 3), }, ) return ( runtime_request_context, [item.model_dump(mode="json") for item in recent_retrospectives], [item.model_dump(mode="json") for item in skill_shortlist], ) async def chat( self, user_id: str, message: str, conversation_id: str | None = None, file_ids: list[str] | None = None, model_name: str | None = None, ) -> tuple[str, str, AsyncGenerator[dict[str, Any], None]]: """ 处理对话请求(流式) """ user_llm_config = await self._get_user_llm_config(user_id, model_name) model_name_used = model_name if model_name and not user_llm_config: raise ValueError("所选模型不可用于聊天,请切换到聊天模型") if user_llm_config: model_name_used = user_llm_config.get("name", model_name) logger.info( "agent_chat_started", extra={ "details": { "mode": "stream", "requested_model_name": model_name, "resolved_model_name": model_name_used, "message_length": len(message or ""), } }, ) if conversation_id: result = await self.db.execute( select(Conversation).where( Conversation.id == conversation_id, Conversation.user_id == user_id, ) ) conv = result.scalar_one_or_none() if conv is None: raise ValueError("会话不存在或无权访问") else: conv = None if not conv: conv = Conversation(user_id=user_id, title=message[:50]) self.db.add(conv) await self.db.commit() await self.db.refresh(conv) conversation_id = conv.id else: conversation_id = conv.id file_context = "" if file_ids: from app.services.document_service import DocumentService doc_svc = DocumentService(self.db) for file_id in file_ids: content = await doc_svc.get_document_content(user_id, file_id) if content: file_context += f"\n\n[用户上传文件内容]\n{content}\n[/文件内容]" full_message = f"{message}\n{file_context}" if file_context else message user_msg = Message( conversation_id=conversation_id, role="user", content=message, attachments=[{"file_ids": file_ids}] if file_ids else None, ) self.db.add(user_msg) await self.db.commit() await self.db.refresh(user_msg) brain_service = BrainService(self.db) await brain_service.create_event( user_id, source_type="conversation", source_id=conversation_id, event_type="message_created", title="User message", content_summary=message[:500], raw_excerpt=message[:2000], metadata_={"role": "user"}, importance_signal=1.0, ) await self.db.commit() memory_ctx = await memory_service.build_memory_context( self.db, user_id, conversation_id, message ) # M.5: Inject recall memories into context (before LLM call) from app.services.memory.recall_injector import MemoryRecallInjector recall_ctx = await MemoryRecallInjector().build_context(self.db, user_id, message) if recall_ctx: memory_ctx = f"{memory_ctx}\n{recall_ctx}" if memory_ctx else recall_ctx assistant_msg = Message( conversation_id=conversation_id, role="assistant", content="", model=model_name_used or "jarvis", attachments=None, ) self.db.add(assistant_msg) await self.db.commit() await self.db.refresh(assistant_msg) def _build_assistant_event_payload(content: str) -> dict[str, Any]: return { "source_type": "conversation", "source_id": conversation_id, "event_type": "message_created", "title": "Assistant message", "content_summary": content[:500], "raw_excerpt": content[:2000], "metadata_": {"role": "assistant"}, "importance_signal": 0.8, } async def run_agent(): collected = "" state: dict[str, Any] | None = None runtime_request_context: RuntimeRequestContext | None = None set_current_user(user_id) try: graph = get_agent_graph() current_datetime_context, current_datetime_reference = ( self._build_current_datetime_context() ) ( runtime_request_context, recalled_retrospectives, skill_shortlist, ) = await self._build_runtime_request_context( request_id=assistant_msg.id, user_id=user_id, conversation=conv, user_query=message, memory_context=memory_ctx, ) state = await self._build_agent_state( request_id=assistant_msg.id, user_id=user_id, conversation=conv, raw_user_query=message, full_message=full_message, memory_context=memory_ctx, current_datetime_context=current_datetime_context, current_datetime_reference=current_datetime_reference, user_llm_config=user_llm_config, runtime_request_context=runtime_request_context, recalled_retrospectives=recalled_retrospectives, skill_shortlist=skill_shortlist, ) state.update(_derive_role_memory_contexts(memory_ctx)) yield self._build_progress_event( "thinking", "Jarvis 正在分析请求", agent="master", step="理解你的问题" ) try: async for event in graph.astream_events(state, version="v2"): kind = event.get("event") event_name = event.get("name", "") metadata = event.get("metadata", {}) data = event.get("data", {}) if kind == "on_chain_start" and event_name in { "master", "schedule_planner", "executor", "librarian", "analyst", }: stage_map = { "master": ("thinking", "Jarvis 正在理解请求"), "schedule_planner": ("planning", "Jarvis 正在编排日程"), "executor": ("tool", "Jarvis 正在执行操作"), "librarian": ("tool", "Jarvis 正在检索知识"), "analyst": ("thinking", "Jarvis 正在分析信息"), } stage, label = stage_map.get( event_name, ("thinking", "Jarvis 正在思考") ) yield self._build_progress_event( stage, label, agent=event_name, step=label ) elif kind == "on_tool_start": yield self._build_progress_event( "tool", f"Jarvis 正在调用工具 {event_name}", agent="executor", tool_name=event_name, step=f"正在执行 {event_name}", ) elif kind == "on_tool_end": tool_result = data.get("output") step = f"已完成 {event_name}" if isinstance(tool_result, str) and len(tool_result) > 0: step = tool_result[:100] yield self._build_progress_event( "tool", f"工具 {event_name} 已完成", agent="executor", tool_name=event_name, step=step, ) elif kind == "on_chat_model_stream": chunk = data.get("chunk") content = _coerce_event_text( getattr(chunk, "content", "") if chunk else "" ) if content: collected += content yield {"type": "chunk", "content": content} elif kind == "on_chain_end": output = data.get("output") final_resp = None if isinstance(output, dict): state.update(output) final_resp = output.get("final_response") if final_resp: final_text = str(final_resp) if final_text != collected: collected = final_text yield {"type": "chunk", "content": final_text} elif kind == "on_chat_model_end": output = data.get("output") final_content = _coerce_event_text( getattr(output, "content", "") if output else "" ) if final_content: final_text = final_content if final_text != collected: collected = final_text yield {"type": "chunk", "content": final_text} except Exception as e: if _is_streaming_rejection_error(e, user_llm_config) and not collected: yield self._build_progress_event( "responding", "Jarvis 正在生成回复", agent="master", step="fallback" ) try: result_state = await graph.ainvoke(state) if isinstance(result_state, dict): state.update(result_state) fallback_content = result_state.get("final_response") or str( result_state.get("messages", [AIMessage(content="")])[-1].content ) collected = str(fallback_content) yield {"type": "chunk", "content": collected} except Exception: logger.exception("llm_sync_fallback_failed") safe_error = "模型服务暂不可用,请稍后再试。" yield {"type": "error", "error": safe_error} collected = f"抱歉,发生错误: {safe_error}" yield {"type": "chunk", "content": collected} else: logger.exception("agent_streaming_failed") if not collected: safe_error = "模型服务暂不可用,请稍后再试。" yield {"type": "error", "error": safe_error} collected = f"抱歉,发生错误: {safe_error}" yield {"type": "chunk", "content": collected} else: yield {"type": "error", "error": str(e)} finally: clear_current_user() try: if collected: assistant_msg.content = collected continuity_snapshot = _build_continuity_snapshot(state or {}) attachments = ( [ { "kind": "agent_continuity_state", **continuity_snapshot, } ] if continuity_snapshot else [] ) if state is not None and runtime_request_context is not None: retrospective = build_session_retrospective( request_id=assistant_msg.id, session_id=conversation_id, user_query=message, state=state, runtime_context=runtime_request_context, ) attachments = append_retrospective_attachment(attachments, retrospective) attachments.append( { "kind": "runtime_observability", "payload": build_runtime_observability_report( state=state, feature_flags=state.get("feature_flags") or {}, ), } ) conv.agent_state = ( { "kind": "agent_continuity_state", **continuity_snapshot, } if continuity_snapshot else None ) await BrainService(self.db).create_event( user_id, **_build_assistant_event_payload(collected), ) assistant_msg.attachments = attachments or None await self.db.commit() await self.db.refresh(assistant_msg) schedule_retrospective_job( user_id=user_id, conversation_id=conversation_id, request_message_id=user_msg.id, response_message_id=assistant_msg.id, query_text=message, final_response=collected, state=state, ) except Exception: logger.exception("save_assistant_message_failed") asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id)) # M.4: Extract memories from conversation asyncio.create_task(self._extract_memories_background(user_id, conversation_id)) return conversation_id, assistant_msg.id, run_agent() async def chat_simple( self, user_id: str, message: str, conversation_id: str | None = None, file_ids: list[str] | None = None, model_name: str | None = None, ) -> tuple[str, str, str, str | None]: """ 简单同步版对话 """ user_llm_config = await self._get_user_llm_config(user_id, model_name) model_name_used = model_name if model_name and not user_llm_config: raise ValueError("所选模型不可用于聊天,请切换到聊天模型") if user_llm_config: model_name_used = user_llm_config.get("name", model_name) if conversation_id: result = await self.db.execute( select(Conversation).where( Conversation.id == conversation_id, Conversation.user_id == user_id, ) ) conv = result.scalar_one_or_none() if conv is None: raise ValueError("会话不存在或无权访问") else: conv = None if not conv: conv = Conversation(user_id=user_id, title=message[:50]) self.db.add(conv) await self.db.commit() await self.db.refresh(conv) conversation_id = conv.id else: conversation_id = conv.id user_msg = Message(conversation_id=conversation_id, role="user", content=message) self.db.add(user_msg) assistant_msg = Message( conversation_id=conversation_id, role="assistant", content="", model=model_name_used or "jarvis", attachments=None, ) self.db.add(assistant_msg) brain_service = BrainService(self.db) await brain_service.create_event( user_id, source_type="conversation", source_id=conversation_id, event_type="message_created", title="User message", content_summary=message[:500], raw_excerpt=message[:2000], metadata_={"role": "user"}, importance_signal=1.0, ) memory_ctx = await memory_service.build_memory_context( self.db, user_id, conversation_id, message ) # M.5: Inject recall memories into context (before LLM call) from app.services.memory.recall_injector import MemoryRecallInjector recall_ctx = await MemoryRecallInjector().build_context(self.db, user_id, message) if recall_ctx: memory_ctx = f"{memory_ctx}\n{recall_ctx}" if memory_ctx else recall_ctx set_current_user(user_id) try: graph = get_agent_graph() current_datetime_context, current_datetime_reference = ( self._build_current_datetime_context() ) ( runtime_request_context, recalled_retrospectives, skill_shortlist, ) = await self._build_runtime_request_context( request_id=assistant_msg.id, user_id=user_id, conversation=conv, user_query=message, memory_context=memory_ctx, ) state = await self._build_agent_state( request_id=assistant_msg.id, user_id=user_id, conversation=conv, raw_user_query=message, full_message=message, memory_context=memory_ctx, current_datetime_context=current_datetime_context, current_datetime_reference=current_datetime_reference, user_llm_config=user_llm_config, runtime_request_context=runtime_request_context, recalled_retrospectives=recalled_retrospectives, skill_shortlist=skill_shortlist, ) state.update(_derive_role_memory_contexts(memory_ctx)) result_state = await graph.ainvoke(state) response_content = result_state.get("final_response") or str( result_state.get("messages", [AIMessage(content="")])[-1].content ) except Exception as e: logger.exception("agent_chat_simple_failed") response_content = "抱歉,发生错误。" finally: clear_current_user() brain_service = BrainService(self.db) await brain_service.create_event( user_id, source_type="conversation", source_id=conversation_id, event_type="message_created", title="Assistant message", content_summary=response_content[:500], raw_excerpt=response_content[:2000], metadata_={"role": "assistant"}, importance_signal=0.8, ) assistant_msg.content = response_content continuity_snapshot = ( _build_continuity_snapshot(result_state) if "result_state" in locals() else None ) attachments = ( [ { "kind": "agent_continuity_state", **continuity_snapshot, } ] if continuity_snapshot else [] ) if "result_state" in locals() and "runtime_request_context" in locals(): retrospective = build_session_retrospective( request_id=assistant_msg.id, session_id=conversation_id, user_query=message, state=result_state, runtime_context=runtime_request_context, ) attachments = append_retrospective_attachment(attachments, retrospective) attachments.append( { "kind": "runtime_observability", "payload": build_runtime_observability_report( state=result_state, feature_flags=result_state.get("feature_flags") or {}, ), } ) conv.agent_state = ( { "kind": "agent_continuity_state", **continuity_snapshot, } if continuity_snapshot else None ) assistant_msg.attachments = attachments or None await self.db.commit() await self.db.refresh(assistant_msg) schedule_retrospective_job( user_id=user_id, conversation_id=conversation_id, request_message_id=user_msg.id, response_message_id=assistant_msg.id, query_text=message, final_response=response_content, state=result_state if "result_state" in locals() else None, ) return conversation_id, assistant_msg.id, response_content, model_name_used