1409 lines
51 KiB
Python
1409 lines
51 KiB
Python
"""Agent runtime streaming service.
|
||
|
||
This module is the LangGraph execution path used by the worker after an
|
||
``AgentRun`` has already been created. It restores input messages, builds the
|
||
agent runtime context, streams model/tool events, persists assistant output and
|
||
extracts UI-facing agent state.
|
||
|
||
Do not put run creation, request id idempotency, queueing or external
|
||
invocation response formatting here. Those responsibilities belong to
|
||
``agent_run_service`` and ``agent_invocation_service`` respectively. Keeping
|
||
this file focused on execution makes normal chat, resume runs and subagent runs
|
||
share the same runtime behavior once they reach the worker.
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import uuid
|
||
from collections.abc import AsyncIterator
|
||
from datetime import UTC, datetime
|
||
from typing import Any, Literal
|
||
|
||
from langchain.messages import AIMessage, AIMessageChunk
|
||
from langgraph.types import Command
|
||
from yuxi import config as conf
|
||
from yuxi.agents.buildin import agent_manager
|
||
from yuxi.agents.context import build_agent_input_context, normalize_agent_context_config
|
||
from yuxi.agents.state import AgentStatePayload
|
||
from yuxi.repositories.agent_repository import AgentRepository
|
||
from yuxi.repositories.agent_run_repository import AgentRunRepository
|
||
from yuxi.repositories.conversation_repository import ConversationRepository
|
||
from yuxi.repositories.subagent_thread_repository import SubagentThreadRepository
|
||
from yuxi.services.conversation_service import serialize_attachment
|
||
from yuxi.services.input_message_service import AgentRunInputMessage
|
||
from yuxi.services.langfuse_service import (
|
||
LangfuseRunContext,
|
||
build_run_context,
|
||
flush_langfuse,
|
||
get_trace_info,
|
||
)
|
||
from yuxi.services.subagent_run_service import serialize_subagent_run_state
|
||
from yuxi.storage.postgres.manager import pg_manager
|
||
from yuxi.storage.postgres.models_business import Agent, User
|
||
from yuxi.utils.guard import content_guard
|
||
from yuxi.utils.logging_config import logger
|
||
from yuxi.utils.question_utils import (
|
||
normalize_questions as _normalize_interrupt_questions,
|
||
)
|
||
from yuxi.utils.thread_utils import extract_thread_id as _metadata_thread_id
|
||
|
||
|
||
def _build_state_files(attachments: list[dict]) -> dict:
|
||
"""将附件列表转换为 StateBackend 格式的 files 字典
|
||
|
||
StateBackend 期望的格式:
|
||
{
|
||
"/attachments/file.md": {
|
||
"content": ["line1", "line2", ...],
|
||
"created_at": "...",
|
||
"modified_at": "...",
|
||
}
|
||
}
|
||
"""
|
||
files = {}
|
||
for attachment in attachments:
|
||
if attachment.get("status") != "parsed":
|
||
continue
|
||
|
||
file_path = attachment.get("file_path")
|
||
markdown = attachment.get("markdown")
|
||
|
||
if not file_path or not markdown:
|
||
continue
|
||
|
||
now = datetime.now(UTC).isoformat()
|
||
# 将 markdown 内容按行拆分
|
||
content_lines = markdown.split("\n")
|
||
files[file_path] = {
|
||
"content": content_lines,
|
||
"created_at": attachment.get("uploaded_at", now),
|
||
"modified_at": attachment.get("uploaded_at", now),
|
||
}
|
||
|
||
return files
|
||
|
||
|
||
def _build_agent_context(agent, input_context: dict):
|
||
context = agent.context_schema()
|
||
context.update(input_context)
|
||
return context
|
||
|
||
|
||
async def _get_langgraph_messages(agent_instance, config_dict, *, context):
|
||
graph = await agent_instance.get_graph(context=context)
|
||
state = await graph.aget_state(config_dict)
|
||
|
||
if not state or not state.values:
|
||
logger.warning("No state found in LangGraph")
|
||
return None
|
||
|
||
return state.values.get("messages", [])
|
||
|
||
|
||
def _build_langfuse_run_context(
|
||
*,
|
||
current_user,
|
||
thread_id: str,
|
||
agent_id: str,
|
||
request_id: str,
|
||
operation: str,
|
||
backend_id: str | None = None,
|
||
message_type: str | None = None,
|
||
meta: dict | None = None,
|
||
) -> LangfuseRunContext:
|
||
extra_metadata = None
|
||
extra_tags = None
|
||
invocation_meta = (meta or {}).get("agent_invocation_meta") if isinstance(meta, dict) else None
|
||
evaluation = invocation_meta.get("evaluation") if isinstance(invocation_meta, dict) else None
|
||
# 如果请求来自智能体评测,添加评测相关的 metadata 和 tags,方便在 Langfuse 中进行过滤和分析
|
||
if (meta or {}).get("source") == "agent_evaluation" or (isinstance(evaluation, dict) and evaluation):
|
||
extra_metadata = {
|
||
"source": "agent_evaluation",
|
||
"feature": "agent_evaluation",
|
||
}
|
||
extra_tags = ["agent_evaluation"]
|
||
if isinstance(evaluation, dict):
|
||
dataset_name = evaluation.get("dataset_name")
|
||
experiment_name = evaluation.get("experiment_name")
|
||
for key in ("dataset_name", "dataset_item_id", "experiment_name"):
|
||
value = evaluation.get(key)
|
||
if value:
|
||
extra_metadata[f"evaluation_{key}"] = str(value)
|
||
if dataset_name:
|
||
extra_tags.append(f"dataset:{dataset_name}")
|
||
if experiment_name:
|
||
extra_tags.append(f"experiment:{experiment_name}")
|
||
|
||
return build_run_context(
|
||
user_id=str(getattr(current_user, "uid", current_user.id)),
|
||
thread_id=thread_id,
|
||
agent_id=agent_id,
|
||
request_id=request_id,
|
||
operation=operation,
|
||
backend_id=backend_id,
|
||
message_type=message_type,
|
||
username=getattr(current_user, "username", None),
|
||
login_user_id=getattr(current_user, "uid", None),
|
||
department_id=getattr(current_user, "department_id", None),
|
||
extra_metadata=extra_metadata,
|
||
extra_tags=extra_tags,
|
||
)
|
||
|
||
|
||
def extract_agent_state(values: dict) -> AgentStatePayload:
|
||
"""从 LangGraph state 中提取 agent 状态"""
|
||
if not isinstance(values, dict):
|
||
return {"todos": [], "files": {}, "artifacts": [], "subagent_runs": [], "token_usage": None}
|
||
|
||
# 直接获取,信任 state 的数据结构
|
||
todos = values.get("todos")
|
||
artifacts = values.get("artifacts")
|
||
subagent_runs = values.get("subagent_runs")
|
||
token_usage = values.get("token_usage")
|
||
result: AgentStatePayload = {
|
||
"todos": list(todos)[:20] if todos else [],
|
||
"files": values.get("files") or {},
|
||
"artifacts": list(artifacts) if artifacts else [],
|
||
"subagent_runs": list(subagent_runs) if subagent_runs else [],
|
||
"token_usage": dict(token_usage) if isinstance(token_usage, dict) else None,
|
||
}
|
||
|
||
return result
|
||
|
||
|
||
def _agent_state_signature(agent_state: AgentStatePayload | dict | None) -> str:
|
||
if not agent_state:
|
||
return ""
|
||
try:
|
||
return json.dumps(agent_state, ensure_ascii=False, sort_keys=True)
|
||
except Exception:
|
||
return str(agent_state)
|
||
|
||
|
||
def _metadata_namespace(metadata: dict | None) -> list[str]:
|
||
if not isinstance(metadata, dict):
|
||
return []
|
||
namespace = metadata.get("namespace")
|
||
if isinstance(namespace, list):
|
||
return [str(item) for item in namespace]
|
||
return []
|
||
|
||
|
||
def _json_safe(value: Any) -> Any:
|
||
if value is None or isinstance(value, str | int | float | bool):
|
||
return value
|
||
if isinstance(value, dict):
|
||
return {str(key): _json_safe(child) for key, child in value.items()}
|
||
if isinstance(value, list | tuple):
|
||
return [_json_safe(child) for child in value]
|
||
if hasattr(value, "model_dump"):
|
||
return _json_safe(value.model_dump())
|
||
return str(value)
|
||
|
||
|
||
def _apply_model_override(input_context: dict, meta: dict | None) -> None:
|
||
"""对话级模型覆盖:meta.model_spec 优先于智能体配置的 model。值已在创建 run 时校验。"""
|
||
model_spec = (meta or {}).get("model_spec")
|
||
model_spec = model_spec.strip() if isinstance(model_spec, str) else model_spec
|
||
if model_spec:
|
||
input_context["model"] = model_spec
|
||
|
||
|
||
def _apply_subagent_runtime_context(input_context: dict, meta: dict | None) -> None:
|
||
"""把子智能体 run 的父线程和文件线程信息注入运行 context。"""
|
||
meta = meta or {}
|
||
# 仅对子智能体类型的 run 生效
|
||
if meta.get("run_type") != "subagent":
|
||
return
|
||
# 这三个线程 ID 由 subagent_run_service 在创建 run 时写入 runtime,
|
||
# 是子智能体区别于普通对话的唯一依据;缺失即上游契约被破坏,直接失败而非静默回退。
|
||
for key in ("parent_thread_id", "file_thread_id", "skills_thread_id"):
|
||
value = str(meta.get(key) or "").strip()
|
||
if not value:
|
||
raise ValueError(f"子智能体运行缺少必需的 {key}")
|
||
input_context[key] = value
|
||
# 标记为子智能体运行,供下游逻辑判断
|
||
input_context["is_subagent_runtime"] = True
|
||
|
||
|
||
def _stream_message_key(metadata: dict | None, namespace: list[str], thread_id: str | None) -> tuple[str, str]:
|
||
if not isinstance(metadata, dict):
|
||
return thread_id or "", "/".join(namespace)
|
||
return thread_id or "", str(metadata.get("run_id") or metadata.get("langgraph_node") or "/".join(namespace))
|
||
|
||
|
||
def _stream_message_id(
|
||
message_ids: dict[tuple[str, str], str],
|
||
key: tuple[str, str],
|
||
preferred: str | None = None,
|
||
) -> str:
|
||
if preferred:
|
||
message_ids[key] = preferred
|
||
return preferred
|
||
return message_ids.setdefault(key, str(uuid.uuid4()))
|
||
|
||
|
||
def _message_chunk_yuxi_events(
|
||
msg_dict: dict[str, Any],
|
||
*,
|
||
message_id: str,
|
||
thread_id: str | None,
|
||
namespace: list[str],
|
||
) -> list[dict[str, Any]]:
|
||
events: list[dict[str, Any]] = []
|
||
route = {"thread_id": thread_id, "namespace": namespace}
|
||
content = msg_dict.get("content")
|
||
additional_kwargs = msg_dict.get("additional_kwargs") if isinstance(msg_dict.get("additional_kwargs"), dict) else {}
|
||
reasoning_content = msg_dict.get("reasoning_content")
|
||
additional_reasoning_content = additional_kwargs.get("reasoning_content")
|
||
|
||
message_event: dict[str, Any] = {"type": "message_delta", "message_id": message_id, **route}
|
||
if isinstance(content, str) and content:
|
||
message_event["content"] = content
|
||
if isinstance(reasoning_content, str) and reasoning_content:
|
||
message_event["reasoning_content"] = reasoning_content
|
||
if isinstance(additional_reasoning_content, str) and additional_reasoning_content:
|
||
message_event["additional_reasoning_content"] = additional_reasoning_content
|
||
if len(message_event) > 4:
|
||
events.append(message_event)
|
||
|
||
tool_call_chunks = msg_dict.get("tool_call_chunks")
|
||
if isinstance(tool_call_chunks, list):
|
||
for tool_call_chunk in tool_call_chunks:
|
||
if not isinstance(tool_call_chunk, dict):
|
||
continue
|
||
args_delta = tool_call_chunk.get("args")
|
||
if args_delta is None:
|
||
args_delta = ""
|
||
elif not isinstance(args_delta, str):
|
||
args_delta = json.dumps(args_delta, ensure_ascii=False)
|
||
if not tool_call_chunk.get("id") and not tool_call_chunk.get("name") and not args_delta:
|
||
continue
|
||
events.append(
|
||
{
|
||
"type": "tool_call_delta",
|
||
"message_id": message_id,
|
||
"tool_call_id": tool_call_chunk.get("id"),
|
||
"name": tool_call_chunk.get("name") or None,
|
||
"args_delta": args_delta,
|
||
"index": tool_call_chunk.get("index") if tool_call_chunk.get("index") is not None else 0,
|
||
**route,
|
||
}
|
||
)
|
||
return events
|
||
|
||
|
||
def _protocol_event_yuxi_event(
|
||
event: dict[str, Any],
|
||
*,
|
||
message_id: str | None,
|
||
thread_id: str | None,
|
||
namespace: list[str],
|
||
) -> dict[str, Any] | None:
|
||
event_name = event.get("event")
|
||
if event_name in {"message-start", "content-block-start", "message-finish"} or not message_id:
|
||
return None
|
||
|
||
route = {"thread_id": thread_id, "namespace": namespace}
|
||
if event_name == "content-block-delta":
|
||
delta = event.get("delta") if isinstance(event.get("delta"), dict) else {}
|
||
text = delta.get("text")
|
||
if delta.get("type") == "text-delta" and isinstance(text, str) and text:
|
||
return {"type": "message_delta", "message_id": message_id, "content": text, **route}
|
||
return None
|
||
|
||
if event_name == "content-block-finish":
|
||
content = event.get("content") if isinstance(event.get("content"), dict) else {}
|
||
if content.get("type") != "tool_call" or not content.get("id") and not content.get("name"):
|
||
return None
|
||
return {
|
||
"type": "tool_call",
|
||
"message_id": message_id,
|
||
"tool_call_id": content.get("id"),
|
||
"name": content.get("name"),
|
||
"args": content.get("args") if content.get("args") is not None else {},
|
||
"index": event.get("index") if event.get("index") is not None else 0,
|
||
**route,
|
||
}
|
||
|
||
return None
|
||
|
||
|
||
def _context_compression_payload(payload: Any) -> dict | None:
|
||
if isinstance(payload, dict) and payload.get("type") == "yuxi.context_compression":
|
||
return payload
|
||
return None
|
||
|
||
|
||
def _stream_event_response(event: dict[str, Any]) -> str:
|
||
if event.get("type") != "message_delta":
|
||
return ""
|
||
return str(event.get("content") or "")
|
||
|
||
|
||
def _message_payload_yuxi_events(
|
||
msg: Any,
|
||
*,
|
||
metadata: dict[str, Any],
|
||
namespace: list[str],
|
||
thread_id: str | None,
|
||
protocol_message_ids: dict[tuple[str, str], str],
|
||
) -> list[dict[str, Any]]:
|
||
message_key = _stream_message_key(metadata, namespace, thread_id)
|
||
if isinstance(msg, dict) and isinstance(msg.get("event"), str):
|
||
preferred_message_id = str(msg["id"]) if msg.get("event") == "message-start" and msg.get("id") else None
|
||
message_id = _stream_message_id(protocol_message_ids, message_key, preferred_message_id)
|
||
stream_event = _protocol_event_yuxi_event(
|
||
msg,
|
||
message_id=message_id,
|
||
thread_id=thread_id,
|
||
namespace=namespace,
|
||
)
|
||
return [stream_event] if stream_event else []
|
||
|
||
if isinstance(msg, AIMessageChunk) or hasattr(msg, "model_dump"):
|
||
msg_dict = msg.model_dump()
|
||
elif isinstance(msg, dict):
|
||
msg_dict = dict(msg)
|
||
else:
|
||
msg_dict = {"content": str(msg)}
|
||
|
||
message_id = str(msg_dict.get("id") or _stream_message_id(protocol_message_ids, message_key))
|
||
return _message_chunk_yuxi_events(
|
||
msg_dict,
|
||
message_id=message_id,
|
||
thread_id=thread_id,
|
||
namespace=namespace,
|
||
)
|
||
|
||
|
||
async def _stream_agent_events(agent, messages, *, input_context=None, **kwargs):
|
||
async for mode, payload in agent.stream_messages_with_state(
|
||
messages,
|
||
input_context=input_context,
|
||
**kwargs,
|
||
):
|
||
yield mode, payload
|
||
|
||
|
||
async def _get_existing_message_ids(conv_repo: ConversationRepository, thread_id: str) -> set[str]:
|
||
existing_messages = await conv_repo.get_messages_by_thread_id(thread_id)
|
||
return {
|
||
msg.extra_metadata["id"]
|
||
for msg in existing_messages
|
||
if msg.extra_metadata and "id" in msg.extra_metadata and isinstance(msg.extra_metadata["id"], str)
|
||
}
|
||
|
||
|
||
async def _save_ai_message(
|
||
conv_repo: ConversationRepository,
|
||
thread_id: str,
|
||
msg_dict: dict,
|
||
trace_info: dict[str, Any] | None = None,
|
||
run_id: str | None = None,
|
||
request_id: str | None = None,
|
||
):
|
||
content = msg_dict.get("content", "")
|
||
tool_calls_data = msg_dict.get("tool_calls") or []
|
||
if isinstance(content, list):
|
||
if not tool_calls_data:
|
||
tool_calls_data = [
|
||
{"id": item.get("id"), "name": item.get("name"), "args": item.get("args") or {}}
|
||
for item in content
|
||
if isinstance(item, dict) and item.get("type") == "tool_call"
|
||
]
|
||
content = "\n".join(
|
||
item.get("text", "") for item in content if isinstance(item, dict) and isinstance(item.get("text"), str)
|
||
)
|
||
elif not isinstance(content, str):
|
||
content = str(content)
|
||
extra_metadata = dict(msg_dict)
|
||
if trace_info:
|
||
extra_metadata.update(trace_info)
|
||
|
||
ai_msg = await conv_repo.add_message_by_thread_id(
|
||
thread_id=thread_id,
|
||
role="assistant",
|
||
content=content,
|
||
message_type="text",
|
||
extra_metadata=extra_metadata,
|
||
run_id=run_id,
|
||
request_id=request_id,
|
||
)
|
||
|
||
if ai_msg and tool_calls_data:
|
||
for tc in tool_calls_data:
|
||
await conv_repo.add_tool_call(
|
||
message_id=ai_msg.id,
|
||
tool_name=tc.get("name") or "unknown",
|
||
tool_input=tc.get("args", {}),
|
||
status="pending",
|
||
langgraph_tool_call_id=tc.get("id"),
|
||
)
|
||
|
||
return ai_msg
|
||
|
||
|
||
async def _save_tool_message(conv_repo: ConversationRepository, msg_dict: dict) -> None:
|
||
tool_call_id = msg_dict.get("tool_call_id")
|
||
content = msg_dict.get("content", "")
|
||
|
||
if not tool_call_id:
|
||
return
|
||
|
||
if isinstance(content, list):
|
||
tool_output = json.dumps(content) if content else ""
|
||
else:
|
||
tool_output = str(content)
|
||
|
||
await conv_repo.update_tool_call_output(
|
||
langgraph_tool_call_id=tool_call_id,
|
||
tool_output=tool_output,
|
||
status="success",
|
||
)
|
||
|
||
|
||
async def save_partial_message(
|
||
conv_repo: ConversationRepository,
|
||
thread_id: str,
|
||
full_msg=None,
|
||
error_message: str | None = None,
|
||
error_type: str = "interrupted",
|
||
trace_info: dict[str, Any] | None = None,
|
||
run_id: str | None = None,
|
||
request_id: str | None = None,
|
||
):
|
||
try:
|
||
extra_metadata = {
|
||
"error_type": error_type,
|
||
"is_error": True,
|
||
"error_message": error_message or f"发生错误: {error_type}",
|
||
}
|
||
if full_msg:
|
||
msg_dict = full_msg.model_dump() if hasattr(full_msg, "model_dump") else {}
|
||
content = full_msg.content if hasattr(full_msg, "content") else str(full_msg)
|
||
extra_metadata = msg_dict | extra_metadata
|
||
else:
|
||
content = ""
|
||
|
||
if trace_info:
|
||
extra_metadata.update(trace_info)
|
||
|
||
return await conv_repo.add_message_by_thread_id(
|
||
thread_id=thread_id,
|
||
role="assistant",
|
||
content=content,
|
||
message_type="text",
|
||
extra_metadata=extra_metadata,
|
||
run_id=run_id,
|
||
request_id=request_id,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Error saving message: {e}")
|
||
return None
|
||
|
||
|
||
async def save_messages_from_langgraph_state(
|
||
agent_instance,
|
||
thread_id: str,
|
||
conv_repo: ConversationRepository,
|
||
config_dict: dict,
|
||
context,
|
||
trace_info: dict[str, Any] | None = None,
|
||
run_id: str | None = None,
|
||
request_id: str | None = None,
|
||
) -> None:
|
||
messages = await _get_langgraph_messages(agent_instance, config_dict, context=context)
|
||
if messages is None:
|
||
return
|
||
|
||
existing_ids = await _get_existing_message_ids(conv_repo, thread_id)
|
||
|
||
last_ai_message = None
|
||
for msg in messages:
|
||
if hasattr(msg, "model_dump"):
|
||
msg_dict = msg.model_dump()
|
||
elif isinstance(msg, dict):
|
||
msg_dict = dict(msg)
|
||
else:
|
||
continue
|
||
|
||
msg_type = msg_dict.get("type", "unknown")
|
||
if msg_type == "unknown":
|
||
role = msg_dict.get("role")
|
||
if role in {"assistant", "ai"}:
|
||
msg_type = "ai"
|
||
elif role in {"user", "human"}:
|
||
msg_type = "human"
|
||
elif role == "tool":
|
||
msg_type = "tool"
|
||
|
||
msg_id = getattr(msg, "id", None) or msg_dict.get("id")
|
||
if msg_type == "human" or msg_id in existing_ids:
|
||
continue
|
||
|
||
if msg_type == "ai":
|
||
last_ai_message = await _save_ai_message(
|
||
conv_repo,
|
||
thread_id,
|
||
msg_dict,
|
||
trace_info=trace_info,
|
||
run_id=run_id,
|
||
request_id=request_id,
|
||
)
|
||
elif msg_type == "tool":
|
||
await _save_tool_message(conv_repo, msg_dict)
|
||
|
||
if run_id and last_ai_message:
|
||
run_repo = AgentRunRepository(conv_repo.db)
|
||
await run_repo.set_output_message(run_id, last_ai_message.id)
|
||
await conv_repo.db.commit()
|
||
|
||
|
||
def _extract_interrupt_info(state) -> Any | None:
|
||
"""从 LangGraph state 中提取中断信息"""
|
||
if hasattr(state, "tasks") and state.tasks:
|
||
for task in state.tasks:
|
||
if hasattr(task, "interrupts") and task.interrupts:
|
||
return task.interrupts[0]
|
||
|
||
interrupt_data = state.values.get("__interrupt__")
|
||
if isinstance(interrupt_data, list) and interrupt_data:
|
||
return interrupt_data[0]
|
||
|
||
return None
|
||
|
||
|
||
def _coerce_interrupt_payload(info: Any) -> dict:
|
||
"""将 LangGraph interrupt 对象转换为 dict 结构。"""
|
||
if isinstance(info, dict):
|
||
return info
|
||
|
||
payload = getattr(info, "value", None)
|
||
if isinstance(payload, dict):
|
||
return payload
|
||
|
||
questions = getattr(info, "questions", None)
|
||
source = getattr(info, "source", None)
|
||
result: dict[str, Any] = {}
|
||
if isinstance(questions, list):
|
||
result["questions"] = questions
|
||
if isinstance(source, str) and source.strip():
|
||
result["source"] = source
|
||
return result
|
||
|
||
|
||
def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any]:
|
||
"""将 interrupt 信息标准化为 ask_user_question_required 载荷。"""
|
||
payload = _coerce_interrupt_payload(info)
|
||
|
||
questions = _normalize_interrupt_questions(payload.get("questions"))
|
||
|
||
if not questions:
|
||
questions = [
|
||
{
|
||
"question_id": str(uuid.uuid4()),
|
||
"question": "请选择一个选项",
|
||
"options": [],
|
||
"multi_select": False,
|
||
"allow_other": True,
|
||
}
|
||
]
|
||
|
||
source = str(payload.get("source") or payload.get("tool_name") or "interrupt")
|
||
|
||
return {
|
||
"questions": questions,
|
||
"source": source,
|
||
"thread_id": thread_id,
|
||
}
|
||
|
||
|
||
def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None:
|
||
"""如果 full_msg 为空且有累积内容,构建 AIMessage"""
|
||
if not full_msg and accumulated_content:
|
||
return AIMessage(content="".join(accumulated_content))
|
||
return full_msg
|
||
|
||
|
||
def _extract_ai_message(messages: list[Any] | None) -> AIMessage | None:
|
||
"""从消息列表中提取最后一条 AIMessage。"""
|
||
if not isinstance(messages, list):
|
||
return None
|
||
|
||
for msg in reversed(messages):
|
||
if isinstance(msg, AIMessage):
|
||
return msg
|
||
|
||
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
|
||
if msg_dict.get("type") == "ai":
|
||
content = msg_dict.get("content", "")
|
||
return msg if hasattr(msg, "content") else AIMessage(content=content)
|
||
|
||
return None
|
||
|
||
|
||
async def _resolve_agent_runtime(
|
||
*,
|
||
db,
|
||
user: User,
|
||
requested_agent_slug: str | None,
|
||
thread_id: str | None,
|
||
agent_kind: Literal["main", "subagent"] = "main",
|
||
) -> tuple[Agent, Any, dict]:
|
||
"""解析智能体运行时,返回 (Agent, backend, agent_config)"""
|
||
agent_repo = AgentRepository(db)
|
||
conv_repo = ConversationRepository(db)
|
||
resolved_agent_slug = requested_agent_slug
|
||
|
||
if thread_id:
|
||
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
||
if conversation:
|
||
if conversation.uid != str(user.uid) or conversation.status == "deleted":
|
||
raise ValueError("对话线程不存在")
|
||
# Conversation.agent_id 是历史字段名,实际保存的是 Agent.slug。
|
||
if requested_agent_slug and requested_agent_slug != conversation.agent_id:
|
||
raise ValueError("已有线程已绑定智能体,不能切换")
|
||
resolved_agent_slug = conversation.agent_id
|
||
|
||
if not resolved_agent_slug:
|
||
raise ValueError("缺少必需的 agent_slug 字段")
|
||
|
||
agent_item = await agent_repo.get_visible_by_slug(slug=resolved_agent_slug, user=user, kind=agent_kind)
|
||
if not agent_item:
|
||
raise ValueError("智能体不存在或无权限访问")
|
||
|
||
backend = agent_manager.get_agent(agent_item.backend_id)
|
||
if not backend:
|
||
raise ValueError(f"智能体后端 {agent_item.backend_id} 不存在")
|
||
|
||
agent_config = await normalize_agent_context_config(
|
||
(agent_item.config_json or {}).get("context", {}),
|
||
db=db,
|
||
user=user,
|
||
context_schema=backend.context_schema,
|
||
)
|
||
return agent_item, backend, agent_config
|
||
|
||
|
||
async def check_and_handle_interrupts(
|
||
agent,
|
||
langgraph_config: dict,
|
||
make_chunk,
|
||
meta: dict,
|
||
thread_id: str,
|
||
context,
|
||
) -> AsyncIterator[bytes]:
|
||
try:
|
||
graph = await agent.get_graph(context=context)
|
||
state = await graph.aget_state(langgraph_config)
|
||
|
||
if not state or not state.values:
|
||
return
|
||
|
||
interrupt_info = _extract_interrupt_info(state)
|
||
if interrupt_info:
|
||
question_payload = _build_ask_user_question_payload(interrupt_info, thread_id)
|
||
meta["interrupt"] = question_payload
|
||
yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload)
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Error checking interrupts: {e}")
|
||
|
||
|
||
async def _ensure_thread_bound_agent(
|
||
*,
|
||
conv_repo: ConversationRepository,
|
||
thread_id: str,
|
||
uid: str,
|
||
agent_item: Agent,
|
||
) -> None:
|
||
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
||
if not conversation:
|
||
await conv_repo.create_conversation(
|
||
uid=uid,
|
||
agent_id=agent_item.slug,
|
||
thread_id=thread_id,
|
||
metadata={"backend_id": agent_item.backend_id},
|
||
)
|
||
return
|
||
|
||
if conversation.agent_id != agent_item.slug:
|
||
raise ValueError("已有线程已绑定智能体,不能切换")
|
||
|
||
|
||
def _normalize_attachment_file_ids(meta: dict | None) -> list[str]:
|
||
file_ids = (meta or {}).get("attachment_file_ids") or []
|
||
if not isinstance(file_ids, list):
|
||
return []
|
||
|
||
normalized = []
|
||
seen = set()
|
||
for file_id in file_ids:
|
||
value = str(file_id).strip()
|
||
if not value or value in seen:
|
||
continue
|
||
seen.add(value)
|
||
normalized.append(value)
|
||
return normalized
|
||
|
||
|
||
async def _bind_request_attachments(
|
||
*,
|
||
conv_repo: ConversationRepository,
|
||
thread_id: str,
|
||
request_id: str,
|
||
attachment_file_ids: list[str],
|
||
) -> list[dict]:
|
||
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
||
if not conversation:
|
||
return []
|
||
|
||
if attachment_file_ids:
|
||
attachments = await conv_repo.bind_attachments_to_request(conversation.id, request_id, attachment_file_ids)
|
||
else:
|
||
attachments = await conv_repo.get_attachments_by_request_id(conversation.id, request_id)
|
||
|
||
return [serialize_attachment(attachment) for attachment in attachments]
|
||
|
||
|
||
async def stream_agent_chat(
|
||
*,
|
||
agent_slug: str,
|
||
thread_id: str | None,
|
||
meta: dict,
|
||
input_message: AgentRunInputMessage,
|
||
current_user,
|
||
db,
|
||
save_user_message: bool = True,
|
||
) -> AsyncIterator[bytes]:
|
||
start_time = asyncio.get_event_loop().time()
|
||
|
||
def make_chunk(content=None, **kwargs):
|
||
chunk_thread_id = kwargs.pop("thread_id", None) or meta.get("thread_id") or thread_id
|
||
return (
|
||
json.dumps(
|
||
{"request_id": meta.get("request_id"), "response": content, "thread_id": chunk_thread_id, **kwargs},
|
||
ensure_ascii=False,
|
||
).encode("utf-8")
|
||
+ b"\n"
|
||
)
|
||
|
||
meta = dict(meta or {})
|
||
if "request_id" not in meta or not meta.get("request_id"):
|
||
logger.warning("请求缺少 request_id,已自动生成一个新的 request_id")
|
||
meta["request_id"] = str(uuid.uuid4())
|
||
|
||
uid = str(current_user.uid)
|
||
if not thread_id:
|
||
thread_id = str(uuid.uuid4())
|
||
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}")
|
||
|
||
query = input_message.content
|
||
image_content = input_message.image_content
|
||
human_message = input_message.require_langchain_message()
|
||
message_type = input_message.message_type
|
||
|
||
if conf.enable_content_guard and await content_guard.check(query):
|
||
yield make_chunk(
|
||
status="error", error_type="content_guard_blocked", error_message="输入内容包含敏感词", meta=meta
|
||
)
|
||
return
|
||
|
||
try:
|
||
agent_item, agent, agent_config = await _resolve_agent_runtime(
|
||
db=db,
|
||
user=current_user,
|
||
requested_agent_slug=agent_slug,
|
||
thread_id=thread_id,
|
||
agent_kind="subagent" if meta.get("run_type") == "subagent" else "main",
|
||
)
|
||
except ValueError as e:
|
||
yield make_chunk(status="error", error_type="invalid_agent", error_message=str(e), meta=meta)
|
||
return
|
||
|
||
meta.update(
|
||
{
|
||
"query": query,
|
||
"agent_slug": agent_item.slug,
|
||
"backend_id": agent_item.backend_id,
|
||
"thread_id": thread_id,
|
||
"uid": current_user.uid,
|
||
"has_image": bool(image_content),
|
||
}
|
||
)
|
||
|
||
messages = [human_message]
|
||
input_context = await build_agent_input_context(
|
||
agent_config,
|
||
thread_id=thread_id,
|
||
uid=uid,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
_apply_model_override(input_context, meta)
|
||
_apply_subagent_runtime_context(input_context, meta)
|
||
context = _build_agent_context(agent, input_context)
|
||
langfuse_run = _build_langfuse_run_context(
|
||
current_user=current_user,
|
||
thread_id=thread_id,
|
||
agent_id=agent_item.slug,
|
||
backend_id=agent_item.backend_id,
|
||
request_id=meta["request_id"],
|
||
operation="agent_chat_stream",
|
||
message_type=message_type,
|
||
meta=meta,
|
||
)
|
||
full_msg = None
|
||
accumulated_content: list[str] = []
|
||
trace_info: dict[str, Any] = {}
|
||
last_agent_state_signature = ""
|
||
|
||
try:
|
||
conv_repo = ConversationRepository(db)
|
||
await _ensure_thread_bound_agent(
|
||
conv_repo=conv_repo,
|
||
thread_id=thread_id,
|
||
uid=uid,
|
||
agent_item=agent_item,
|
||
)
|
||
|
||
request_attachments = await _bind_request_attachments(
|
||
conv_repo=conv_repo,
|
||
thread_id=thread_id,
|
||
request_id=meta["request_id"],
|
||
attachment_file_ids=_normalize_attachment_file_ids(meta),
|
||
)
|
||
|
||
init_msg = {
|
||
"role": "user",
|
||
"content": query,
|
||
"type": "human",
|
||
"message_type": message_type,
|
||
"extra_metadata": {
|
||
"request_id": meta.get("request_id"),
|
||
"attachments": request_attachments,
|
||
},
|
||
}
|
||
if image_content:
|
||
init_msg["image_content"] = image_content
|
||
yield make_chunk(status="init", meta=meta, msg=init_msg)
|
||
|
||
if save_user_message:
|
||
try:
|
||
await conv_repo.add_message_by_thread_id(
|
||
thread_id=thread_id,
|
||
role="user",
|
||
content=query,
|
||
message_type=message_type,
|
||
image_content=image_content,
|
||
extra_metadata={
|
||
"raw_message": human_message.model_dump(),
|
||
"request_id": meta.get("request_id"),
|
||
"attachments": request_attachments,
|
||
},
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error saving user message: {e}")
|
||
|
||
# 先构建 langgraph_config
|
||
langgraph_config = {"configurable": {"thread_id": thread_id, "uid": uid}}
|
||
|
||
# LangGraph 会自动从 checkpointer 恢复 state(包括 uploads)
|
||
# 无需手动加载或传递
|
||
|
||
protocol_message_ids: dict[tuple[str, str], str] = {}
|
||
async for mode, payload in _stream_agent_events(
|
||
agent,
|
||
messages,
|
||
input_context=input_context,
|
||
callbacks=langfuse_run.callbacks,
|
||
metadata=langfuse_run.metadata,
|
||
tags=langfuse_run.tags,
|
||
):
|
||
if mode == "values":
|
||
agent_state = extract_agent_state(payload if isinstance(payload, dict) else {})
|
||
signature = _agent_state_signature(agent_state)
|
||
if signature and signature != last_agent_state_signature:
|
||
last_agent_state_signature = signature
|
||
yield make_chunk(status="agent_state", agent_state=agent_state, meta=meta)
|
||
continue
|
||
|
||
if mode == "custom":
|
||
compression = _context_compression_payload(payload)
|
||
if compression is not None:
|
||
yield make_chunk(status="context_compression", compression=compression, meta=meta)
|
||
continue
|
||
|
||
if mode == "stream_event":
|
||
yield make_chunk(
|
||
status="stream_event",
|
||
event=payload,
|
||
namespace=payload.get("namespace") if isinstance(payload, dict) else [],
|
||
meta=meta,
|
||
thread_id=payload.get("thread_id") if isinstance(payload, dict) else None,
|
||
)
|
||
continue
|
||
|
||
msg, metadata = payload
|
||
namespace = _metadata_namespace(metadata)
|
||
chunk_thread_id = _metadata_thread_id(metadata, thread_id if not namespace else None)
|
||
if namespace and not chunk_thread_id:
|
||
continue
|
||
|
||
is_subagent_chunk = bool(chunk_thread_id and chunk_thread_id != thread_id)
|
||
stream_events = _message_payload_yuxi_events(
|
||
msg,
|
||
metadata=metadata,
|
||
namespace=namespace,
|
||
thread_id=chunk_thread_id,
|
||
protocol_message_ids=protocol_message_ids,
|
||
)
|
||
|
||
for stream_event in stream_events:
|
||
content = _stream_event_response(stream_event)
|
||
if not is_subagent_chunk and content:
|
||
trace_info = get_trace_info(langfuse_run)
|
||
accumulated_content.append(content)
|
||
content_for_check = "".join(accumulated_content[-10:])
|
||
if conf.enable_content_guard and await content_guard.check_with_keywords(content_for_check):
|
||
full_msg = AIMessage(content="".join(accumulated_content))
|
||
await save_partial_message(
|
||
conv_repo,
|
||
thread_id,
|
||
full_msg,
|
||
"content_guard_blocked",
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
|
||
yield make_chunk(status="interrupted", message="检测到敏感内容,已中断输出", meta=meta)
|
||
return
|
||
|
||
yield make_chunk(
|
||
content=content,
|
||
stream_event=stream_event,
|
||
metadata=metadata,
|
||
status="loading",
|
||
thread_id=chunk_thread_id,
|
||
)
|
||
|
||
full_msg = _ensure_full_msg(full_msg, accumulated_content)
|
||
trace_info = get_trace_info(langfuse_run)
|
||
|
||
if conf.enable_content_guard and hasattr(full_msg, "content") and await content_guard.check(full_msg.content):
|
||
await save_partial_message(
|
||
conv_repo,
|
||
thread_id,
|
||
full_msg,
|
||
"content_guard_blocked",
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
|
||
yield make_chunk(status="interrupted", message="检测到敏感内容,已中断输出", meta=meta)
|
||
return
|
||
|
||
interrupted = False
|
||
async for chunk in check_and_handle_interrupts(agent, langgraph_config, make_chunk, meta, thread_id, context):
|
||
interrupted = True
|
||
yield chunk
|
||
|
||
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
|
||
try:
|
||
graph = await agent.get_graph(context=context)
|
||
state = await graph.aget_state(langgraph_config)
|
||
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
|
||
except Exception:
|
||
agent_state = {}
|
||
|
||
final_signature = _agent_state_signature(agent_state)
|
||
if final_signature and final_signature != last_agent_state_signature:
|
||
last_agent_state_signature = final_signature
|
||
yield make_chunk(status="agent_state", agent_state=agent_state, meta=meta)
|
||
|
||
# 先存储数据库,再返回 finished,避免前端查询时数据未落库
|
||
try:
|
||
await save_messages_from_langgraph_state(
|
||
agent_instance=agent,
|
||
thread_id=thread_id,
|
||
conv_repo=conv_repo,
|
||
config_dict=langgraph_config,
|
||
context=context,
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"Error saving messages from LangGraph state: {e}")
|
||
yield make_chunk(status="warning", message=f"消息保存失败: {e}", meta=meta)
|
||
|
||
if interrupted:
|
||
return
|
||
|
||
yield make_chunk(status="finished", meta=meta)
|
||
|
||
except (asyncio.CancelledError, ConnectionError) as e:
|
||
logger.warning(f"Client disconnected, cancelling stream: {e}")
|
||
|
||
async def save_cleanup():
|
||
nonlocal full_msg
|
||
full_msg = _ensure_full_msg(full_msg, accumulated_content)
|
||
|
||
async with pg_manager.get_async_session_context() as new_db:
|
||
new_conv_repo = ConversationRepository(new_db)
|
||
await save_partial_message(
|
||
new_conv_repo,
|
||
thread_id,
|
||
full_msg=full_msg,
|
||
error_message="对话已中断" if not full_msg else None,
|
||
error_type="interrupted",
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
|
||
cleanup_task = asyncio.create_task(save_cleanup())
|
||
try:
|
||
await asyncio.shield(cleanup_task)
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception as exc:
|
||
logger.error(f"Error during cleanup save: {exc}")
|
||
|
||
yield make_chunk(status="interrupted", message="对话已中断", meta=meta)
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Error streaming messages: {e}")
|
||
|
||
error_msg = f"Error streaming messages: {e}"
|
||
error_type = "unexpected_error"
|
||
|
||
full_msg = _ensure_full_msg(full_msg, accumulated_content)
|
||
|
||
async with pg_manager.get_async_session_context() as new_db:
|
||
new_conv_repo = ConversationRepository(new_db)
|
||
await save_partial_message(
|
||
new_conv_repo,
|
||
thread_id,
|
||
full_msg=full_msg,
|
||
error_message=error_msg,
|
||
error_type=error_type,
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
|
||
yield make_chunk(status="error", error_type=error_type, error_message=error_msg, meta=meta)
|
||
finally:
|
||
flush_langfuse()
|
||
|
||
|
||
async def stream_agent_resume(
|
||
*,
|
||
thread_id: str,
|
||
resume_input: Any,
|
||
meta: dict,
|
||
current_user,
|
||
db,
|
||
) -> AsyncIterator[bytes]:
|
||
start_time = asyncio.get_event_loop().time()
|
||
|
||
def make_resume_chunk(content=None, **kwargs):
|
||
chunk_thread_id = kwargs.pop("thread_id", None) or meta.get("thread_id") or thread_id
|
||
return (
|
||
json.dumps(
|
||
{"request_id": meta.get("request_id"), "response": content, "thread_id": chunk_thread_id, **kwargs},
|
||
ensure_ascii=False,
|
||
).encode("utf-8")
|
||
+ b"\n"
|
||
)
|
||
|
||
yield make_resume_chunk(status="init", meta=meta)
|
||
|
||
resume_command = Command(resume=resume_input)
|
||
|
||
uid = str(current_user.uid)
|
||
try:
|
||
agent_item, agent, agent_config = await _resolve_agent_runtime(
|
||
db=db,
|
||
user=current_user,
|
||
requested_agent_slug=None,
|
||
thread_id=thread_id,
|
||
)
|
||
except ValueError as e:
|
||
yield make_resume_chunk(status="error", error_type="invalid_agent", error_message=str(e), meta=meta)
|
||
return
|
||
|
||
meta["agent_slug"] = agent_item.slug
|
||
meta["backend_id"] = agent_item.backend_id
|
||
input_context = await build_agent_input_context(
|
||
agent_config or {},
|
||
thread_id=thread_id,
|
||
uid=uid,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
_apply_model_override(input_context, meta)
|
||
context = _build_agent_context(agent, input_context)
|
||
langfuse_run = _build_langfuse_run_context(
|
||
current_user=current_user,
|
||
thread_id=thread_id,
|
||
agent_id=agent_item.slug,
|
||
backend_id=agent_item.backend_id,
|
||
request_id=meta.get("request_id") or str(uuid.uuid4()),
|
||
operation="agent_chat_resume",
|
||
message_type="resume",
|
||
meta=meta,
|
||
)
|
||
trace_info: dict[str, Any] = {}
|
||
last_agent_state_signature = ""
|
||
|
||
stream_source = agent.stream_resume_with_state(
|
||
resume_command,
|
||
input_context=input_context,
|
||
callbacks=langfuse_run.callbacks,
|
||
metadata=langfuse_run.metadata,
|
||
tags=langfuse_run.tags,
|
||
)
|
||
|
||
protocol_message_ids: dict[tuple[str, str], str] = {}
|
||
|
||
try:
|
||
async for mode, payload in stream_source:
|
||
if mode == "values":
|
||
agent_state = extract_agent_state(payload if isinstance(payload, dict) else {})
|
||
signature = _agent_state_signature(agent_state)
|
||
if signature and signature != last_agent_state_signature:
|
||
last_agent_state_signature = signature
|
||
yield make_resume_chunk(status="agent_state", agent_state=agent_state, meta=meta)
|
||
continue
|
||
|
||
if mode == "stream_event":
|
||
event_payload = payload if isinstance(payload, dict) else {}
|
||
yield make_resume_chunk(
|
||
status="stream_event",
|
||
event=event_payload,
|
||
namespace=event_payload.get("namespace") or [],
|
||
meta=meta,
|
||
thread_id=event_payload.get("thread_id"),
|
||
)
|
||
continue
|
||
|
||
if mode == "custom":
|
||
compression = _context_compression_payload(payload)
|
||
if compression is not None:
|
||
yield make_resume_chunk(status="context_compression", compression=compression, meta=meta)
|
||
continue
|
||
|
||
if mode != "messages":
|
||
continue
|
||
|
||
msg, metadata = payload
|
||
metadata = dict(metadata or {})
|
||
namespace = _metadata_namespace(metadata)
|
||
chunk_thread_id = _metadata_thread_id(metadata, thread_id if not namespace else None)
|
||
if namespace and not chunk_thread_id:
|
||
continue
|
||
|
||
if chunk_thread_id == thread_id:
|
||
trace_info = get_trace_info(langfuse_run)
|
||
|
||
stream_events = _message_payload_yuxi_events(
|
||
msg,
|
||
metadata=metadata,
|
||
namespace=namespace,
|
||
thread_id=chunk_thread_id,
|
||
protocol_message_ids=protocol_message_ids,
|
||
)
|
||
|
||
for stream_event in stream_events:
|
||
content = _stream_event_response(stream_event)
|
||
yield make_resume_chunk(
|
||
content=content,
|
||
stream_event=stream_event,
|
||
metadata=metadata,
|
||
status="loading",
|
||
thread_id=chunk_thread_id,
|
||
)
|
||
|
||
langgraph_config = {"configurable": {"thread_id": thread_id, "uid": uid}}
|
||
interrupted = False
|
||
async for chunk in check_and_handle_interrupts(
|
||
agent, langgraph_config, make_resume_chunk, meta, thread_id, context
|
||
):
|
||
interrupted = True
|
||
yield chunk
|
||
|
||
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
|
||
|
||
try:
|
||
graph = await agent.get_graph(context=context)
|
||
state = await graph.aget_state(langgraph_config)
|
||
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
|
||
except Exception:
|
||
agent_state = {}
|
||
|
||
final_signature = _agent_state_signature(agent_state)
|
||
if final_signature and final_signature != last_agent_state_signature:
|
||
yield make_resume_chunk(status="agent_state", agent_state=agent_state, meta=meta)
|
||
|
||
# 先存储数据库,再返回 finished,避免前端查询时数据未落库
|
||
conv_repo = ConversationRepository(db)
|
||
try:
|
||
await save_messages_from_langgraph_state(
|
||
agent_instance=agent,
|
||
thread_id=thread_id,
|
||
conv_repo=conv_repo,
|
||
config_dict=langgraph_config,
|
||
context=context,
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"Error saving messages from LangGraph state: {e}")
|
||
yield make_resume_chunk(status="warning", message=f"消息保存失败: {e}", meta=meta)
|
||
|
||
if interrupted:
|
||
return
|
||
|
||
yield make_resume_chunk(status="finished", meta=meta)
|
||
|
||
except (asyncio.CancelledError, ConnectionError) as e:
|
||
logger.warning(f"Client disconnected during resume: {e}")
|
||
|
||
async with pg_manager.get_async_session_context() as new_db:
|
||
new_conv_repo = ConversationRepository(new_db)
|
||
await save_partial_message(
|
||
new_conv_repo,
|
||
thread_id,
|
||
error_message="对话恢复已中断",
|
||
error_type="resume_interrupted",
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
|
||
yield make_resume_chunk(status="interrupted", message="对话恢复已中断", meta=meta)
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Error during resume: {e}")
|
||
|
||
async with pg_manager.get_async_session_context() as new_db:
|
||
new_conv_repo = ConversationRepository(new_db)
|
||
await save_partial_message(
|
||
new_conv_repo,
|
||
thread_id,
|
||
error_message=f"Error during resume: {e}",
|
||
error_type="resume_error",
|
||
trace_info=trace_info,
|
||
run_id=meta.get("run_id"),
|
||
request_id=meta.get("request_id"),
|
||
)
|
||
|
||
yield make_resume_chunk(message=f"Error during resume: {e}", status="error")
|
||
finally:
|
||
flush_langfuse()
|
||
|
||
|
||
def _serialize_state_messages(values: dict[str, Any]) -> list[dict[str, Any]]:
|
||
messages = values.get("messages") if isinstance(values, dict) else None
|
||
if not isinstance(messages, list):
|
||
return []
|
||
serialized = []
|
||
for message in messages:
|
||
if hasattr(message, "model_dump"):
|
||
serialized.append(message.model_dump())
|
||
elif isinstance(message, dict):
|
||
serialized.append(dict(message))
|
||
else:
|
||
serialized.append({"type": "unknown", "content": str(message)})
|
||
return serialized
|
||
|
||
|
||
async def _read_checkpoint_state(agent, *, uid: str, thread_id: str, context):
|
||
graph = await agent.get_graph(context=context)
|
||
langgraph_config = {"configurable": {"uid": uid, "thread_id": thread_id}}
|
||
return await graph.aget_state(langgraph_config)
|
||
|
||
|
||
async def get_agent_state_view(
|
||
*,
|
||
thread_id: str,
|
||
current_user: User,
|
||
db,
|
||
include_messages: bool = False,
|
||
) -> dict:
|
||
from fastapi import HTTPException
|
||
|
||
current_uid = str(current_user.uid)
|
||
conv_repo = ConversationRepository(db)
|
||
agent_repo = AgentRepository(db)
|
||
run_repo = AgentRunRepository(db)
|
||
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
||
if conversation:
|
||
if conversation.uid != str(current_uid) or conversation.status == "deleted":
|
||
raise HTTPException(status_code=404, detail="对话线程不存在")
|
||
|
||
agent_item = await agent_repo.get_by_slug(conversation.agent_id)
|
||
if not agent_item:
|
||
raise HTTPException(status_code=404, detail="智能体不存在")
|
||
agent = agent_manager.get_agent(agent_item.backend_id)
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail="智能体后端不存在")
|
||
agent_config = await normalize_agent_context_config(
|
||
(agent_item.config_json or {}).get("context", {}),
|
||
db=db,
|
||
user=current_user,
|
||
context_schema=agent.context_schema,
|
||
)
|
||
input_context = await build_agent_input_context(
|
||
agent_config,
|
||
thread_id=thread_id,
|
||
uid=current_uid,
|
||
)
|
||
latest_run = await run_repo.get_latest_run_by_thread_for_user(thread_id, current_uid)
|
||
if latest_run and isinstance(latest_run.input_payload, dict):
|
||
model_spec = latest_run.input_payload.get("model_spec")
|
||
if isinstance(model_spec, str) and model_spec.strip():
|
||
input_context["model"] = model_spec.strip()
|
||
context = _build_agent_context(agent, input_context)
|
||
state = await _read_checkpoint_state(agent, uid=current_uid, thread_id=thread_id, context=context)
|
||
values = getattr(state, "values", {}) if state else {}
|
||
response = {"agent_state": extract_agent_state(values)}
|
||
relation = await SubagentThreadRepository(db).get_by_child_conversation_for_user(
|
||
conversation.id,
|
||
str(current_uid),
|
||
)
|
||
if relation:
|
||
parent_conversation = await conv_repo.get_conversation_by_id(relation.parent_conversation_id)
|
||
if (
|
||
not parent_conversation
|
||
or parent_conversation.uid != str(current_uid)
|
||
or parent_conversation.status == "deleted"
|
||
):
|
||
raise HTTPException(status_code=404, detail="父对话线程不存在")
|
||
response["parent_thread_id"] = parent_conversation.thread_id
|
||
response["subagent_thread"] = relation.to_dict()
|
||
latest_run = await run_repo.get_latest_subagent_run_by_thread_for_user(
|
||
thread_id,
|
||
str(current_uid),
|
||
)
|
||
if latest_run:
|
||
try:
|
||
response["subagent_run"] = serialize_subagent_run_state(latest_run)
|
||
except ValueError as exc:
|
||
logger.error(f"子智能体运行记录格式异常: thread_id={thread_id}, run_id={latest_run.id}, {exc}")
|
||
raise HTTPException(status_code=500, detail="子智能体运行记录格式异常") from exc
|
||
if include_messages:
|
||
response["messages"] = _serialize_state_messages(values)
|
||
return response
|
||
|
||
# 子智能体线程在创建时必然同时写入子对话与线程关系(见 SubagentRunService.start),
|
||
# 由上面的 conversation 分支统一处理;走到这里说明该 thread 没有对应对话,即线程不存在。
|
||
raise HTTPException(status_code=404, detail="对话线程不存在")
|