Files
JARVIS/backend/app/services/agent_service.py

1184 lines
46 KiB
Python
Raw Normal View History

2026-03-21 10:13:29 +08:00
"""
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
2026-03-21 10:13:29 +08:00
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
2026-03-21 10:13:29 +08:00
from app.models.conversation import Conversation, Message
from app.models.user import User
2026-03-21 10:13:29 +08:00
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
2026-03-21 10:13:29 +08:00
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
)
2026-03-21 10:13:29 +08:00
return any(marker in error_text for marker in markers)
2026-03-21 10:13:29 +08:00
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
2026-03-21 10:13:29 +08:00
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],
)
2026-03-21 10:13:29 +08:00
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]]:
2026-03-21 10:13:29 +08:00
"""
处理对话请求流式
"""
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 ""),
}
},
)
2026-03-21 10:13:29 +08:00
if conversation_id:
result = await self.db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
2026-03-21 10:13:29 +08:00
)
conv = result.scalar_one_or_none()
if conv is None:
raise ValueError("会话不存在或无权访问")
2026-03-21 10:13:29 +08:00
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
2026-03-21 10:13:29 +08:00
user_msg = Message(
conversation_id=conversation_id,
role="user",
content=message,
attachments=[{"file_ids": file_ids}] if file_ids else None,
2026-03-21 10:13:29 +08:00
)
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
2026-03-21 10:13:29 +08:00
assistant_msg = Message(
conversation_id=conversation_id,
role="assistant",
content="",
model=model_name_used or "jarvis",
attachments=None,
2026-03-21 10:13:29 +08:00
)
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,
}
2026-03-21 10:13:29 +08:00
async def run_agent():
collected = ""
state: dict[str, Any] | None = None
runtime_request_context: RuntimeRequestContext | None = None
2026-03-21 10:13:29 +08:00
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))
2026-03-21 10:13:29 +08:00
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)}
2026-03-21 10:13:29 +08:00
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,
)
2026-03-21 10:13:29 +08:00
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))
2026-03-21 10:13:29 +08:00
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]:
2026-03-21 10:13:29 +08:00
"""
简单同步版对话
2026-03-21 10:13:29 +08:00
"""
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)
2026-03-21 10:13:29 +08:00
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:
2026-03-21 10:13:29 +08:00
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
2026-03-21 10:13:29 +08:00
user_msg = Message(conversation_id=conversation_id, role="user", content=message)
2026-03-21 10:13:29 +08:00
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
2026-03-21 10:13:29 +08:00
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
)
2026-03-21 10:13:29 +08:00
except Exception as e:
logger.exception("agent_chat_simple_failed")
response_content = "抱歉,发生错误。"
2026-03-21 10:13:29 +08:00
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,
2026-03-21 10:13:29 +08:00
)
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
2026-03-21 10:13:29 +08:00
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