738 lines
30 KiB
Python
738 lines
30 KiB
Python
"""Background agent execution.
|
||
|
||
Runs an agent graph inside an ``asyncio.Task``, publishing events to
|
||
a :class:`StreamBridge` as they are produced.
|
||
|
||
Uses ``graph.astream(stream_mode=[...])`` which gives correct full-state
|
||
snapshots for ``values`` mode, proper ``{node: writes}`` for ``updates``,
|
||
and ``(chunk, metadata)`` tuples for ``messages`` mode.
|
||
|
||
Note: ``events`` mode is not supported through the gateway — it requires
|
||
``graph.astream_events()`` which cannot simultaneously produce ``values``
|
||
snapshots. The JS open-source LangGraph API server works around this via
|
||
internal checkpoint callbacks that are not exposed in the Python public API.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import copy
|
||
import inspect
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from functools import lru_cache
|
||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||
|
||
from langgraph.checkpoint.base import empty_checkpoint
|
||
|
||
if TYPE_CHECKING:
|
||
from langchain_core.messages import HumanMessage
|
||
|
||
from deerflow.agents.middlewares.title_middleware import (
|
||
finalize_thread_title,
|
||
log_title_debug,
|
||
)
|
||
from deerflow.config.app_config import AppConfig
|
||
from deerflow.runtime.serialization import serialize
|
||
from deerflow.runtime.stream_bridge import StreamBridge
|
||
|
||
from .manager import RunManager, RunRecord
|
||
from .schemas import RunStatus
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Valid stream_mode values for LangGraph's graph.astream()
|
||
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
|
||
|
||
|
||
def _log_background_title_finalize_failure(thread_id: str):
|
||
def _done(task: asyncio.Task[bool]) -> None:
|
||
try:
|
||
task.result()
|
||
except Exception:
|
||
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
|
||
|
||
return _done
|
||
|
||
|
||
def _build_runtime_context(
|
||
thread_id: str,
|
||
run_id: str,
|
||
caller_context: Any | None,
|
||
app_config: AppConfig | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Build the dict that becomes ``ToolRuntime.context`` for the run.
|
||
|
||
Always includes ``thread_id`` and ``run_id``. Additional keys from the caller's
|
||
``config['context']`` (e.g. ``agent_name`` for the bootstrap flow — issue #2677)
|
||
are merged in but never override ``thread_id``/``run_id``. The resolved
|
||
``AppConfig`` is added by the worker so tools can consume it without ambient
|
||
global lookups.
|
||
|
||
langgraph 1.1+ surfaces this as ``runtime.context`` via the parent runtime stored
|
||
under ``config['configurable']['__pregel_runtime']`` — see
|
||
``langgraph.pregel.main`` where ``parent_runtime.merge(...)`` is invoked.
|
||
"""
|
||
runtime_ctx: dict[str, Any] = {"thread_id": thread_id, "run_id": run_id}
|
||
if isinstance(caller_context, dict):
|
||
for key, value in caller_context.items():
|
||
runtime_ctx.setdefault(key, value)
|
||
if app_config is not None:
|
||
runtime_ctx["app_config"] = app_config
|
||
return runtime_ctx
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RunContext:
|
||
"""Infrastructure dependencies for a single agent run.
|
||
|
||
Groups checkpointer, store, and persistence-related singletons so that
|
||
``run_agent`` (and any future callers) receive one object instead of a
|
||
growing list of keyword arguments.
|
||
"""
|
||
|
||
checkpointer: Any
|
||
store: Any | None = field(default=None)
|
||
event_store: Any | None = field(default=None)
|
||
run_events_config: Any | None = field(default=None)
|
||
thread_store: Any | None = field(default=None)
|
||
app_config: AppConfig | None = field(default=None)
|
||
|
||
|
||
def _install_runtime_context(config: dict, runtime_context: dict[str, Any]) -> None:
|
||
existing_context = config.get("context")
|
||
if isinstance(existing_context, dict):
|
||
existing_context.setdefault("thread_id", runtime_context["thread_id"])
|
||
existing_context.setdefault("run_id", runtime_context["run_id"])
|
||
if "app_config" in runtime_context:
|
||
existing_context["app_config"] = runtime_context["app_config"]
|
||
return
|
||
|
||
config["context"] = dict(runtime_context)
|
||
|
||
|
||
def _compute_agent_factory_supports_app_config(agent_factory: Any) -> bool:
|
||
try:
|
||
return "app_config" in inspect.signature(agent_factory).parameters
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
|
||
@lru_cache(maxsize=128)
|
||
def _cached_agent_factory_supports_app_config(agent_factory: Any) -> bool:
|
||
return _compute_agent_factory_supports_app_config(agent_factory)
|
||
|
||
|
||
def _agent_factory_supports_app_config(agent_factory: Any) -> bool:
|
||
try:
|
||
return _cached_agent_factory_supports_app_config(agent_factory)
|
||
except TypeError:
|
||
# Some callable instances are unhashable; fall back to a direct check.
|
||
return _compute_agent_factory_supports_app_config(agent_factory)
|
||
|
||
|
||
async def run_agent(
|
||
bridge: StreamBridge,
|
||
run_manager: RunManager,
|
||
record: RunRecord,
|
||
*,
|
||
ctx: RunContext,
|
||
agent_factory: Any,
|
||
graph_input: dict,
|
||
config: dict,
|
||
stream_modes: list[str] | None = None,
|
||
stream_subgraphs: bool = False,
|
||
interrupt_before: list[str] | Literal["*"] | None = None,
|
||
interrupt_after: list[str] | Literal["*"] | None = None,
|
||
command: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""Execute an agent in the background, publishing events to *bridge*.
|
||
|
||
``command`` 来自 LangGraph SDK 的 ``Command`` 协议(``{resume, update, goto}``)。
|
||
存在时把它翻译成 ``langgraph.types.Command`` 并作为 ``astream`` 的入参 ——
|
||
Pregel 检查到 Command 输入会从最近 checkpoint 处恢复(典型场景:interrupt 之后
|
||
的 ``Command(resume=user_input)``);不存在时仍按 ``graph_input`` dict 起新 run。
|
||
AI 写作的 4 个干预卡就靠这个把 user_input 传回 ``pause_*_confirm`` 节点。
|
||
"""
|
||
|
||
# Unpack infrastructure dependencies from RunContext.
|
||
checkpointer = ctx.checkpointer
|
||
store = ctx.store
|
||
event_store = ctx.event_store
|
||
run_events_config = ctx.run_events_config
|
||
thread_store = ctx.thread_store
|
||
|
||
run_id = record.run_id
|
||
thread_id = record.thread_id
|
||
requested_modes: set[str] = set(stream_modes or ["values"])
|
||
pre_run_checkpoint_id: str | None = None
|
||
pre_run_snapshot: dict[str, Any] | None = None
|
||
snapshot_capture_failed = False
|
||
|
||
journal = None
|
||
|
||
journal = None
|
||
|
||
# Track whether "events" was requested but skipped
|
||
if "events" in requested_modes:
|
||
logger.info(
|
||
"Run %s: 'events' stream_mode not supported in gateway (requires astream_events + checkpoint callbacks). Skipping.",
|
||
run_id,
|
||
)
|
||
|
||
try:
|
||
# Initialize RunJournal + write human_message event.
|
||
# These are inside the try block so any exception (e.g. a DB
|
||
# error writing the event) flows through the except/finally
|
||
# path that publishes an "end" event to the SSE bridge —
|
||
# otherwise a failure here would leave the stream hanging
|
||
# with no terminator.
|
||
if event_store is not None:
|
||
from deerflow.runtime.journal import RunJournal
|
||
|
||
journal = RunJournal(
|
||
run_id=run_id,
|
||
thread_id=thread_id,
|
||
event_store=event_store,
|
||
track_token_usage=getattr(run_events_config, "track_token_usage", True),
|
||
)
|
||
|
||
# 1. Mark running
|
||
await run_manager.set_status(run_id, RunStatus.running)
|
||
|
||
# Snapshot the latest pre-run checkpoint so rollback can restore it.
|
||
if checkpointer is not None:
|
||
try:
|
||
config_for_check = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||
ckpt_tuple = await checkpointer.aget_tuple(config_for_check)
|
||
if ckpt_tuple is not None:
|
||
ckpt_config = getattr(ckpt_tuple, "config", {}).get("configurable", {})
|
||
pre_run_checkpoint_id = ckpt_config.get("checkpoint_id")
|
||
pre_run_snapshot = {
|
||
"checkpoint_ns": ckpt_config.get("checkpoint_ns", ""),
|
||
"checkpoint": copy.deepcopy(getattr(ckpt_tuple, "checkpoint", {})),
|
||
"metadata": copy.deepcopy(getattr(ckpt_tuple, "metadata", {})),
|
||
"pending_writes": copy.deepcopy(getattr(ckpt_tuple, "pending_writes", []) or []),
|
||
}
|
||
except Exception:
|
||
snapshot_capture_failed = True
|
||
logger.warning("Could not capture pre-run checkpoint snapshot for run %s", run_id, exc_info=True)
|
||
|
||
# 2. Publish metadata — useStream needs both run_id AND thread_id
|
||
await bridge.publish(
|
||
run_id,
|
||
"metadata",
|
||
{
|
||
"run_id": run_id,
|
||
"thread_id": thread_id,
|
||
},
|
||
)
|
||
|
||
# 3. Build the agent
|
||
from langchain_core.runnables import RunnableConfig
|
||
from langgraph.runtime import Runtime
|
||
|
||
# Inject runtime context so middlewares and tools (via ToolRuntime.context) can
|
||
# access thread-level data. langgraph-cli does this automatically; we must do it
|
||
# manually here because we drive the graph through ``agent.astream(config=...)``
|
||
# without passing the official ``context=`` parameter.
|
||
runtime_ctx = _build_runtime_context(thread_id, run_id, config.get("context"), ctx.app_config)
|
||
_install_runtime_context(config, runtime_ctx)
|
||
runtime = Runtime(context=cast(Any, runtime_ctx), store=store)
|
||
config.setdefault("configurable", {})["__pregel_runtime"] = runtime
|
||
|
||
# Inject RunJournal as a LangChain callback handler.
|
||
# on_llm_end captures token usage; on_chain_start/end captures lifecycle.
|
||
if journal is not None:
|
||
config.setdefault("callbacks", []).append(journal)
|
||
|
||
runnable_config = RunnableConfig(**config)
|
||
if ctx.app_config is not None and _agent_factory_supports_app_config(agent_factory):
|
||
agent = agent_factory(config=runnable_config, app_config=ctx.app_config)
|
||
else:
|
||
agent = agent_factory(config=runnable_config)
|
||
|
||
# 4. Attach checkpointer and store
|
||
if checkpointer is not None:
|
||
agent.checkpointer = checkpointer
|
||
if store is not None:
|
||
agent.store = store
|
||
|
||
# 5. Set interrupt nodes
|
||
if interrupt_before:
|
||
agent.interrupt_before_nodes = interrupt_before
|
||
if interrupt_after:
|
||
agent.interrupt_after_nodes = interrupt_after
|
||
|
||
# 6. Build LangGraph stream_mode list
|
||
# "events" is NOT a valid astream mode — skip it
|
||
# "messages-tuple" maps to LangGraph's "messages" mode
|
||
lg_modes: list[str] = []
|
||
for m in requested_modes:
|
||
if m == "messages-tuple":
|
||
lg_modes.append("messages")
|
||
elif m == "events":
|
||
# Skipped — see log above
|
||
continue
|
||
elif m in _VALID_LG_MODES:
|
||
lg_modes.append(m)
|
||
if not lg_modes:
|
||
lg_modes = ["values"]
|
||
|
||
# Deduplicate while preserving order
|
||
seen: set[str] = set()
|
||
deduped: list[str] = []
|
||
for m in lg_modes:
|
||
if m not in seen:
|
||
seen.add(m)
|
||
deduped.append(m)
|
||
lg_modes = deduped
|
||
|
||
logger.info("Run %s: streaming with modes %s (requested: %s)", run_id, lg_modes, requested_modes)
|
||
|
||
# 7. Stream using graph.astream
|
||
# 如果客户端传了 Command(典型:interrupt 后续提的 ``Command(resume=...)``),
|
||
# 把它翻译成 langgraph.types.Command 作为 astream 输入;否则按 graph_input dict
|
||
# 起新一轮。Pregel 检查到 Command 会从最近 checkpoint 处恢复,而不是重头跑。
|
||
stream_input: Any = graph_input
|
||
if command is not None:
|
||
# 有 resume/update/goto(如 AI 写作 4 个干预卡的 Command(resume=user_input))→
|
||
# 构造 langgraph.types.Command,Pregel 从 interrupt 处恢复。
|
||
# **空 command(无任何指令)= "从最近 checkpoint 继续执行挂起的任务"**:AI 写作
|
||
# "暂停后继续" —— 暂停时硬取消了活跃节点(非 interrupt),continue 要让该阶段节点
|
||
# 从 checkpoint 续跑。此时必须用 **None 输入**让 Pregel resume pending tasks;
|
||
# 既不能用 graph_input={}(空 dict 被当新一轮输入 → 从图入口 intent 重跑),也不能
|
||
# 构造 Command()(没有挂起的 interrupt 可消费,LangGraph 内部会抛
|
||
# "cannot access local variable 'resume_is_map'")。
|
||
has_directive = any(command.get(k) is not None for k in ("resume", "update", "goto"))
|
||
if has_directive:
|
||
from langgraph.types import Command as _LGCommand
|
||
|
||
stream_input = _LGCommand(
|
||
resume=command.get("resume"),
|
||
update=command.get("update"),
|
||
goto=command.get("goto"),
|
||
)
|
||
else:
|
||
stream_input = None
|
||
|
||
if len(lg_modes) == 1 and not stream_subgraphs:
|
||
# Single mode, no subgraphs: astream yields raw chunks
|
||
single_mode = lg_modes[0]
|
||
async for chunk in agent.astream(stream_input, config=runnable_config, stream_mode=single_mode):
|
||
if record.abort_event.is_set():
|
||
logger.info("Run %s abort requested — stopping", run_id)
|
||
break
|
||
sse_event = _lg_mode_to_sse_event(single_mode)
|
||
await bridge.publish(run_id, sse_event, serialize(chunk, mode=single_mode))
|
||
else:
|
||
# Multiple modes or subgraphs: astream yields tuples
|
||
async for item in agent.astream(
|
||
stream_input,
|
||
config=runnable_config,
|
||
stream_mode=lg_modes,
|
||
subgraphs=stream_subgraphs,
|
||
):
|
||
if record.abort_event.is_set():
|
||
logger.info("Run %s abort requested — stopping", run_id)
|
||
break
|
||
|
||
mode, chunk = _unpack_stream_item(item, lg_modes, stream_subgraphs)
|
||
if mode is None:
|
||
continue
|
||
|
||
sse_event = _lg_mode_to_sse_event(mode)
|
||
await bridge.publish(run_id, sse_event, serialize(chunk, mode=mode))
|
||
|
||
# 8. Final status
|
||
if record.abort_event.is_set():
|
||
action = record.abort_action
|
||
if action == "rollback":
|
||
await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
|
||
try:
|
||
await _rollback_to_pre_run_checkpoint(
|
||
checkpointer=checkpointer,
|
||
thread_id=thread_id,
|
||
run_id=run_id,
|
||
pre_run_checkpoint_id=pre_run_checkpoint_id,
|
||
pre_run_snapshot=pre_run_snapshot,
|
||
snapshot_capture_failed=snapshot_capture_failed,
|
||
)
|
||
logger.info("Run %s rolled back to pre-run checkpoint %s", run_id, pre_run_checkpoint_id)
|
||
except Exception:
|
||
logger.warning("Failed to rollback checkpoint for run %s", run_id, exc_info=True)
|
||
else:
|
||
await run_manager.set_status(run_id, RunStatus.interrupted)
|
||
else:
|
||
# LLMErrorHandlingMiddleware intentionally converts an exhausted
|
||
# provider failure into a user-safe AIMessage instead of raising.
|
||
# That keeps a malformed upstream response out of the SSE transport,
|
||
# but previously made this worker record the run as ``success``.
|
||
# The UI consequently had no error event to show and looked as if
|
||
# generation had silently stopped halfway through. Inspect the
|
||
# terminal checkpoint before declaring success so the persisted run
|
||
# state and the user-visible SSE outcome stay consistent.
|
||
terminal_llm_error = await _terminal_llm_error_reason(
|
||
checkpointer=checkpointer,
|
||
thread_id=thread_id,
|
||
)
|
||
if terminal_llm_error is not None:
|
||
error_message = "本轮模型生成未能完成,已保留当前对话内容,请稍后重试。"
|
||
logger.warning(
|
||
"Run %s ended with LLM fallback message (reason=%s)",
|
||
run_id,
|
||
terminal_llm_error,
|
||
)
|
||
await run_manager.set_status(
|
||
run_id,
|
||
RunStatus.error,
|
||
error=error_message,
|
||
)
|
||
await bridge.publish(
|
||
run_id,
|
||
"error",
|
||
{
|
||
"message": error_message,
|
||
"name": "ModelGenerationError",
|
||
},
|
||
)
|
||
else:
|
||
await run_manager.set_status(run_id, RunStatus.success)
|
||
|
||
except asyncio.CancelledError:
|
||
action = record.abort_action
|
||
if action == "rollback":
|
||
await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
|
||
try:
|
||
await _rollback_to_pre_run_checkpoint(
|
||
checkpointer=checkpointer,
|
||
thread_id=thread_id,
|
||
run_id=run_id,
|
||
pre_run_checkpoint_id=pre_run_checkpoint_id,
|
||
pre_run_snapshot=pre_run_snapshot,
|
||
snapshot_capture_failed=snapshot_capture_failed,
|
||
)
|
||
logger.info("Run %s was cancelled and rolled back", run_id)
|
||
except Exception:
|
||
logger.warning("Run %s cancellation rollback failed", run_id, exc_info=True)
|
||
else:
|
||
await run_manager.set_status(run_id, RunStatus.interrupted)
|
||
logger.info("Run %s was cancelled", run_id)
|
||
|
||
except Exception as exc:
|
||
error_msg = f"{exc}"
|
||
logger.exception("Run %s failed: %s", run_id, error_msg)
|
||
await run_manager.set_status(run_id, RunStatus.error, error=error_msg)
|
||
await bridge.publish(
|
||
run_id,
|
||
"error",
|
||
{
|
||
"message": error_msg,
|
||
"name": type(exc).__name__,
|
||
},
|
||
)
|
||
|
||
finally:
|
||
# Flush any buffered journal events and persist completion data
|
||
if journal is not None:
|
||
try:
|
||
await journal.flush()
|
||
except Exception:
|
||
logger.warning("Failed to flush journal for run %s", run_id, exc_info=True)
|
||
|
||
try:
|
||
# Persist token usage + convenience fields to RunStore
|
||
completion = journal.get_completion_data()
|
||
await run_manager.update_run_completion(run_id, status=record.status.value, **completion)
|
||
except Exception:
|
||
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
|
||
|
||
# Sync title from checkpoint to threads_meta.display_name
|
||
if checkpointer is not None and thread_store is not None:
|
||
try:
|
||
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
|
||
if ckpt_tuple is not None:
|
||
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
|
||
channel_values = ckpt.get("channel_values", {}) or {}
|
||
title = channel_values.get("title")
|
||
if title:
|
||
await thread_store.update_display_name(thread_id, title)
|
||
if (
|
||
record.status == RunStatus.success
|
||
and channel_values.get("title_provisional") is True
|
||
):
|
||
log_title_debug(
|
||
"Run %s: scheduling background title generation for thread %s",
|
||
run_id,
|
||
thread_id,
|
||
)
|
||
title_task = asyncio.create_task(
|
||
finalize_thread_title(
|
||
checkpointer=checkpointer,
|
||
thread_store=thread_store,
|
||
thread_id=thread_id,
|
||
source_state=copy.deepcopy(channel_values),
|
||
app_config=ctx.app_config,
|
||
),
|
||
name=f"title-finalize:{thread_id}",
|
||
)
|
||
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
|
||
elif record.status == RunStatus.success:
|
||
log_title_debug(
|
||
"Run %s: background title generation not scheduled for thread %s because title_provisional=%r title=%r",
|
||
run_id,
|
||
thread_id,
|
||
channel_values.get("title_provisional"),
|
||
channel_values.get("title"),
|
||
)
|
||
except Exception:
|
||
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
|
||
|
||
# Update threads_meta status based on run outcome
|
||
if thread_store is not None:
|
||
try:
|
||
final_status = "idle" if record.status == RunStatus.success else record.status.value
|
||
await thread_store.update_status(thread_id, final_status)
|
||
except Exception:
|
||
logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
|
||
|
||
await bridge.publish_end(run_id)
|
||
asyncio.create_task(bridge.cleanup(run_id, delay=60))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_name: str, *args: Any, **kwargs: Any) -> Any:
|
||
"""Call a checkpointer method, supporting async and sync variants."""
|
||
method = getattr(checkpointer, async_name, None) or getattr(checkpointer, sync_name, None)
|
||
if method is None:
|
||
raise AttributeError(f"Missing checkpointer method: {async_name}/{sync_name}")
|
||
result = method(*args, **kwargs)
|
||
if inspect.isawaitable(result):
|
||
return await result
|
||
return result
|
||
|
||
|
||
async def _terminal_llm_error_reason(
|
||
*,
|
||
checkpointer: Any | None,
|
||
thread_id: str,
|
||
) -> str | None:
|
||
"""Return the terminal LLM fallback reason, if this run ended in one.
|
||
|
||
``LLMErrorHandlingMiddleware`` persists its fallback as an AI message with
|
||
an internal marker. A normal agent graph treats that message as a valid
|
||
result and completes without an exception, so the worker must check the
|
||
saved conversation rather than relying solely on ``astream`` raising.
|
||
"""
|
||
if checkpointer is None:
|
||
return None
|
||
|
||
try:
|
||
checkpoint_config = {
|
||
"configurable": {
|
||
"thread_id": thread_id,
|
||
"checkpoint_ns": "",
|
||
}
|
||
}
|
||
checkpoint_tuple = await _call_checkpointer_method(
|
||
checkpointer,
|
||
"aget_tuple",
|
||
"get_tuple",
|
||
checkpoint_config,
|
||
)
|
||
if checkpoint_tuple is None:
|
||
return None
|
||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||
channel_values = checkpoint.get("channel_values", {})
|
||
messages = channel_values.get("messages", []) if isinstance(channel_values, dict) else []
|
||
if not isinstance(messages, list):
|
||
return None
|
||
|
||
# Walk backwards to the latest assistant message. Tool/human messages
|
||
# may be appended around it by middleware, but an older fallback should
|
||
# never turn a later successful response into an error.
|
||
for message in reversed(messages):
|
||
message_type = message.get("type") if isinstance(message, dict) else getattr(message, "type", None)
|
||
if message_type not in {"ai", "assistant"}:
|
||
continue
|
||
from deerflow.agents.middlewares.llm_error_handling_middleware import (
|
||
is_llm_error_message,
|
||
)
|
||
|
||
return is_llm_error_message(message)
|
||
except Exception:
|
||
# A status check must never turn a completed answer into a failed run
|
||
# merely because a custom checkpointer cannot expose its latest tuple.
|
||
logger.debug(
|
||
"Could not inspect terminal LLM state for thread %s",
|
||
thread_id,
|
||
exc_info=True,
|
||
)
|
||
return None
|
||
|
||
|
||
async def _rollback_to_pre_run_checkpoint(
|
||
*,
|
||
checkpointer: Any,
|
||
thread_id: str,
|
||
run_id: str,
|
||
pre_run_checkpoint_id: str | None,
|
||
pre_run_snapshot: dict[str, Any] | None,
|
||
snapshot_capture_failed: bool,
|
||
) -> None:
|
||
"""Restore thread state to the checkpoint snapshot captured before run start."""
|
||
if checkpointer is None:
|
||
logger.info("Run %s rollback requested but no checkpointer is configured", run_id)
|
||
return
|
||
|
||
if snapshot_capture_failed:
|
||
logger.warning("Run %s rollback skipped: pre-run checkpoint snapshot capture failed", run_id)
|
||
return
|
||
|
||
if pre_run_snapshot is None:
|
||
await _call_checkpointer_method(checkpointer, "adelete_thread", "delete_thread", thread_id)
|
||
logger.info("Run %s rollback reset thread %s to empty state", run_id, thread_id)
|
||
return
|
||
|
||
checkpoint_to_restore = None
|
||
metadata_to_restore: dict[str, Any] = {}
|
||
checkpoint_ns = ""
|
||
checkpoint = pre_run_snapshot.get("checkpoint")
|
||
if not isinstance(checkpoint, dict):
|
||
logger.warning("Run %s rollback skipped: invalid pre-run checkpoint snapshot", run_id)
|
||
return
|
||
checkpoint_to_restore = checkpoint
|
||
if checkpoint_to_restore.get("id") is None and pre_run_checkpoint_id is not None:
|
||
checkpoint_to_restore = {**checkpoint_to_restore, "id": pre_run_checkpoint_id}
|
||
if checkpoint_to_restore.get("id") is None:
|
||
logger.warning("Run %s rollback skipped: pre-run checkpoint has no checkpoint id", run_id)
|
||
return
|
||
restore_marker = _new_checkpoint_marker()
|
||
checkpoint_to_restore = {
|
||
**checkpoint_to_restore,
|
||
"id": restore_marker["id"],
|
||
"ts": restore_marker["ts"],
|
||
}
|
||
metadata = pre_run_snapshot.get("metadata", {})
|
||
metadata_to_restore = metadata if isinstance(metadata, dict) else {}
|
||
raw_checkpoint_ns = pre_run_snapshot.get("checkpoint_ns")
|
||
checkpoint_ns = raw_checkpoint_ns if isinstance(raw_checkpoint_ns, str) else ""
|
||
|
||
channel_versions = checkpoint_to_restore.get("channel_versions")
|
||
new_versions = dict(channel_versions) if isinstance(channel_versions, dict) else {}
|
||
|
||
restore_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": checkpoint_ns}}
|
||
restored_config = await _call_checkpointer_method(
|
||
checkpointer,
|
||
"aput",
|
||
"put",
|
||
restore_config,
|
||
checkpoint_to_restore,
|
||
metadata_to_restore if isinstance(metadata_to_restore, dict) else {},
|
||
new_versions,
|
||
)
|
||
if not isinstance(restored_config, dict):
|
||
raise RuntimeError(f"Run {run_id} rollback restore returned invalid config: expected dict")
|
||
restored_configurable = restored_config.get("configurable", {})
|
||
if not isinstance(restored_configurable, dict):
|
||
raise RuntimeError(f"Run {run_id} rollback restore returned invalid config payload")
|
||
restored_checkpoint_id = restored_configurable.get("checkpoint_id")
|
||
if not restored_checkpoint_id:
|
||
raise RuntimeError(f"Run {run_id} rollback restore did not return checkpoint_id")
|
||
|
||
pending_writes = pre_run_snapshot.get("pending_writes", [])
|
||
if not pending_writes:
|
||
return
|
||
|
||
writes_by_task: dict[str, list[tuple[str, Any]]] = {}
|
||
for item in pending_writes:
|
||
if not isinstance(item, (tuple, list)) or len(item) != 3:
|
||
raise RuntimeError(f"Run {run_id} rollback failed: pending_write is not a 3-tuple: {item!r}")
|
||
task_id, channel, value = item
|
||
if not isinstance(channel, str):
|
||
raise RuntimeError(f"Run {run_id} rollback failed: pending_write has non-string channel: task_id={task_id!r}, channel={channel!r}")
|
||
writes_by_task.setdefault(str(task_id), []).append((channel, value))
|
||
|
||
for task_id, writes in writes_by_task.items():
|
||
await _call_checkpointer_method(
|
||
checkpointer,
|
||
"aput_writes",
|
||
"put_writes",
|
||
restored_config,
|
||
writes,
|
||
task_id=task_id,
|
||
)
|
||
|
||
|
||
def _new_checkpoint_marker() -> dict[str, str]:
|
||
marker = empty_checkpoint()
|
||
return {"id": marker["id"], "ts": marker["ts"]}
|
||
|
||
|
||
def _lg_mode_to_sse_event(mode: str) -> str:
|
||
"""Map LangGraph internal stream_mode name to SSE event name.
|
||
|
||
LangGraph's ``astream(stream_mode="messages")`` produces message
|
||
tuples. The SSE protocol calls this ``messages-tuple`` when the
|
||
client explicitly requests it, but the default SSE event name used
|
||
by LangGraph Platform is simply ``"messages"``.
|
||
"""
|
||
# All LG modes map 1:1 to SSE event names — "messages" stays "messages"
|
||
return mode
|
||
|
||
|
||
def _extract_human_message(graph_input: dict) -> HumanMessage | None:
|
||
"""Extract or construct a HumanMessage from graph_input for event recording.
|
||
|
||
Returns a LangChain HumanMessage so callers can use .model_dump() to get
|
||
the checkpoint-aligned serialization format.
|
||
"""
|
||
from langchain_core.messages import HumanMessage
|
||
|
||
messages = graph_input.get("messages")
|
||
if not messages:
|
||
return None
|
||
last = messages[-1] if isinstance(messages, list) else messages
|
||
if isinstance(last, HumanMessage):
|
||
return last
|
||
if isinstance(last, str):
|
||
return HumanMessage(content=last) if last else None
|
||
if hasattr(last, "content"):
|
||
content = last.content
|
||
return HumanMessage(content=content)
|
||
if isinstance(last, dict):
|
||
content = last.get("content", "")
|
||
return HumanMessage(content=content) if content else None
|
||
return None
|
||
|
||
|
||
def _unpack_stream_item(
|
||
item: Any,
|
||
lg_modes: list[str],
|
||
stream_subgraphs: bool,
|
||
) -> tuple[str | None, Any]:
|
||
"""Unpack a multi-mode or subgraph stream item into (mode, chunk).
|
||
|
||
Returns ``(None, None)`` if the item cannot be parsed.
|
||
"""
|
||
if stream_subgraphs:
|
||
if isinstance(item, tuple) and len(item) == 3:
|
||
_ns, mode, chunk = item
|
||
return str(mode), chunk
|
||
if isinstance(item, tuple) and len(item) == 2:
|
||
mode, chunk = item
|
||
return str(mode), chunk
|
||
return None, None
|
||
|
||
if isinstance(item, tuple) and len(item) == 2:
|
||
mode, chunk = item
|
||
return str(mode), chunk
|
||
|
||
# Fallback: single-element output from first mode
|
||
return lg_modes[0] if lg_modes else None, item
|