Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1184 lines
46 KiB
Python
1184 lines
46 KiB
Python
"""
|
||
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
|