Files

1009 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 collections.abc import Awaitable
from dataclasses import dataclass, field
from functools import lru_cache
from time import perf_counter
from typing import TYPE_CHECKING, Any, Literal, cast
from langgraph.checkpoint.base import empty_checkpoint
from langgraph.errors import GraphRecursionError
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 deerflow.utils.background_init import run_background_init
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"}
# This budget applies only after graph execution has returned. Long-running
# tools, research and subagents retain their own execution/cancellation policy.
_RUN_FINALIZATION_TIMEOUT_SECONDS = 10.0
_PENDING_COMPLETION_TASKS: set[asyncio.Future[Any]] = set()
_BACKGROUND_TITLE_TASKS: set[asyncio.Task[Any]] = set()
def _log_background_title_finalize_failure(thread_id: str):
def _done(task: asyncio.Task[Any]) -> None:
_BACKGROUND_TITLE_TASKS.discard(task)
try:
task.result()
except Exception:
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
return _done
async def _finalize_title_and_clear_provisional_marker(
*, checkpointer: Any, thread_store: Any, thread_id: str, source_state: dict[str, Any], app_config: AppConfig | None
) -> bool:
"""Finalize the title and always clear the cheap metadata polling marker."""
try:
return await finalize_thread_title(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id=thread_id,
source_state=source_state,
app_config=app_config,
)
finally:
try:
await thread_store.update_metadata(
thread_id, {"_deerflow_title_provisional": False}, user_id=None
)
except Exception:
logger.debug("Failed to clear provisional-title marker for thread %s", thread_id, exc_info=True)
async def _sync_thread_title_after_run(
*,
checkpointer: Any,
thread_store: Any,
thread_id: str,
run_manager: RunManager,
record: RunRecord,
app_config: AppConfig | None,
) -> None:
"""Read title state and generate a final title outside run completion."""
try:
if not await _completion_is_current(run_manager, record):
return
ckpt_tuple = await checkpointer.aget_tuple(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
)
if ckpt_tuple is None or not await _completion_is_current(run_manager, record):
return
checkpoint = getattr(ckpt_tuple, "checkpoint", {}) or {}
channel_values = (checkpoint.get("channel_values", {}) or {}) if isinstance(checkpoint, dict) else {}
title = channel_values.get("title")
if title:
await thread_store.update_display_name(thread_id, title)
provisional = channel_values.get("title_provisional") is True
if provisional:
await thread_store.update_metadata(
thread_id,
{"_deerflow_title_provisional": record.status == RunStatus.success},
)
if record.status == RunStatus.success and provisional:
log_title_debug(
"Run %s: scheduling background title generation for thread %s",
record.run_id,
thread_id,
)
await _finalize_title_and_clear_provisional_marker(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id=thread_id,
source_state=copy.deepcopy(channel_values),
app_config=app_config,
)
elif record.status == RunStatus.success:
log_title_debug(
"Run %s: title generation not needed for thread %s (title_provisional=%r title=%r)",
record.run_id,
thread_id,
channel_values.get("title_provisional"),
title,
)
except asyncio.CancelledError:
raise
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id, exc_info=True)
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)
def _build_agent_with_timing(agent_factory: Any, config: Any, app_config: AppConfig | None, run_id: str) -> Any:
# Keep the synchronous contract for callers whose factories need their
# current event loop. The async wrapper only offloads opted-in factories.
started = perf_counter()
outcome = "error"
try:
if app_config is not None and _agent_factory_supports_app_config(agent_factory):
agent = agent_factory(config=config, app_config=app_config)
else:
agent = agent_factory(config=config)
outcome = "ok"
return agent
finally:
elapsed_ms = (perf_counter() - started) * 1000
log = logger.warning if elapsed_ms >= 500 else logger.debug
try:
log("AGENT_BUILD_TIMING run_id=%s outcome=%s elapsed_ms=%.2f", run_id, outcome, elapsed_ms)
except Exception:
# Timing is diagnostic only; preserve the factory result/exception
# even if a deployment's custom log handler is unavailable.
pass
async def build_agent_with_timing(agent_factory: Any, config: Any, app_config: AppConfig | None, run_id: str) -> Any:
"""Build agents from async entry points with shared admission and context.
All async callers of an opted-in factory must use this path: a synchronous
build on the owner loop can block MCP discovery scheduled by another build.
"""
if getattr(agent_factory, "_deerflow_background_build", False) is not True:
return _build_agent_with_timing(agent_factory, config, app_config, run_id)
return await run_background_init(_build_agent_with_timing, agent_factory, config, app_config, run_id)
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
run_id = record.run_id
thread_id = record.thread_id
requested_modes: set[str] = set(stream_modes or ["values"])
internal_stream_call_ids: set[str] = set()
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
metadata_event: dict[str, Any] = {
"run_id": run_id,
"thread_id": thread_id,
}
retrieval_trace = (record.metadata or {}).get("knowledge_retrieval_trace")
if isinstance(retrieval_trace, dict):
metadata_event["knowledge_retrieval_trace"] = retrieval_trace
metadata_event["knowledge_retrieval_url"] = (
f"/api/threads/{thread_id}/reference-batches"
)
await bridge.publish(run_id, "metadata", metadata_event)
# 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)
agent = await build_agent_with_timing(agent_factory, runnable_config, ctx.app_config, run_id)
if record.abort_event.is_set():
raise asyncio.CancelledError
# 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)
serialized = serialize(
chunk,
mode=single_mode,
known_internal_tool_call_ids=internal_stream_call_ids,
)
if serialized is not None:
await bridge.publish(run_id, sse_event, serialized)
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)
serialized = serialize(
chunk,
mode=mode,
known_internal_tool_call_ids=internal_stream_call_ids,
)
if serialized is not None:
await bridge.publish(run_id, sse_event, serialized)
# 8. Final status
if record.abort_event.is_set():
action = record.abort_action
if action == "rollback":
await _set_terminal_status(run_manager, record, 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 _set_terminal_status(run_manager, record, 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 _await_completion_io(
_terminal_llm_error_reason(checkpointer=checkpointer, thread_id=thread_id),
run_id=run_id,
phase="terminal_checkpoint",
)
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 _set_terminal_status(
run_manager,
record,
RunStatus.error,
error=error_message,
)
await bridge.publish(
run_id,
"error",
{
"message": error_message,
"name": "ModelGenerationError",
},
)
else:
await _set_terminal_status(run_manager, record, RunStatus.success)
except asyncio.CancelledError:
action = record.abort_action
if action == "rollback":
await _set_terminal_status(run_manager, record, 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 _set_terminal_status(run_manager, record, RunStatus.interrupted)
logger.info("Run %s was cancelled", run_id)
except GraphRecursionError:
logger.info("Run %s reached its step budget", run_id)
try:
await _await_completion_io(
_finish_step_limited_run(agent, runnable_config, bridge, run_id),
run_id=run_id,
phase="step_limit_notice",
)
await _set_terminal_status(run_manager, record, RunStatus.interrupted)
except Exception as exc:
logger.exception("Could not save step-limit notice for run %s", run_id)
await _set_terminal_status(run_manager, record, RunStatus.error, error=str(exc))
await bridge.publish(run_id, "error", {
"message": "本次问答已达到步骤上限,但保存暂停提示失败,请刷新对话后重试。",
"name": "StepLimitSaveError",
})
except Exception as exc:
error_msg = f"{exc}"
logger.exception("Run %s failed: %s", run_id, error_msg)
await _set_terminal_status(run_manager, record, RunStatus.error, error=error_msg)
await bridge.publish(
run_id,
"error",
{
"message": error_msg,
"name": type(exc).__name__,
},
)
finally:
try:
await _await_completion_io(
_finalize_run(
journal=journal,
run_manager=run_manager,
record=record,
ctx=ctx,
),
run_id=run_id,
phase="metadata_cleanup",
)
except TimeoutError:
logger.warning("Run %s completion metadata exceeded its time budget; ending SSE", run_id)
except asyncio.CancelledError:
# A second cancellation can arrive while handling the original
# cancellation or during normal cleanup. Never lose the terminator.
logger.info("Run %s completion metadata was cancelled; ending SSE", run_id)
except Exception:
logger.warning("Failed to finalize run %s (non-fatal)", run_id, exc_info=True)
finally:
await bridge.publish_end(run_id)
asyncio.create_task(bridge.cleanup(run_id, delay=60))
async def _finish_step_limited_run(agent: Any, config: dict, bridge: StreamBridge, run_id: str) -> None:
"""Save a normal chat notice and close outstanding tool calls for the next turn.
Never rerun pending tools during recovery: they may have side effects.
New user input starts a fresh budget while keeping the existing transcript.
"""
from langchain_core.messages import AIMessage, ToolMessage
config = {**config, "configurable": dict(config.get("configurable", {}))}
# Recover from the run's latest checkpoint, even when its input selected
# an older checkpoint explicitly.
config["configurable"].pop("checkpoint_id", None)
snapshot = await agent.aget_state(config)
messages = snapshot.values.get("messages", [])
pending: dict[str, str] = {}
for message in messages:
if isinstance(message, AIMessage):
for call in message.tool_calls:
pending[call["id"]] = call["name"]
elif isinstance(message, ToolMessage):
pending.pop(message.tool_call_id, None)
additions: list = [
ToolMessage(
content="本次问答达到步骤上限,工具执行已暂停;此调用没有可用的完成结果。",
tool_call_id=call_id, name=name, status="error",
)
for call_id, name in pending.items()
]
notice = AIMessage(
id=f"step-limit-{run_id}",
content=(
"本次问答已达到步骤上限,已暂停处理。已有对话内容已保留。\n\n"
"如需接着完成,请发送“继续”;也可以补充要求,指定接下来优先处理的内容。"
),
additional_kwargs={"step_limit_reached": True},
)
additions.append(notice)
# Explicitly identify the model writer to avoid ambiguous last-writer
# inference when parallel tool nodes reached the budget together.
await agent.aupdate_state(config, {"messages": additions}, as_node="model" if "model" in agent.nodes else None)
await agent.aupdate_state(config, None, as_node="__end__")
final_state = await agent.aget_state(config)
await bridge.publish(run_id, "messages", serialize((notice, {"langgraph_node": "model"}), mode="messages"))
await bridge.publish(run_id, "values", serialize(final_state.values, mode="values"))
async def _await_completion_io[T](
operation: Awaitable[T],
*,
run_id: str,
phase: str,
) -> T:
"""Bound completion-only I/O even if a backend delays cancellation.
Unlike wait_for, wait does not wait indefinitely for the cancelled
operation to acknowledge cancellation. Do not use this for executing the
graph: detaching a live tool would release run concurrency prematurely.
"""
task = asyncio.ensure_future(operation)
_PENDING_COMPLETION_TASKS.add(task)
def consume_result(completed: asyncio.Future) -> None:
_PENDING_COMPLETION_TASKS.discard(completed)
if not completed.cancelled():
completed.exception()
task.add_done_callback(consume_result)
try:
done, _ = await asyncio.wait({task}, timeout=_RUN_FINALIZATION_TIMEOUT_SECONDS)
if not done:
logger.warning("RUN_COMPLETION_TIMEOUT run_id=%s phase=%s", run_id, phase)
raise TimeoutError(f"Run completion timed out during {phase}")
return task.result()
finally:
if not task.done():
task.cancel()
async def _set_terminal_status(run_manager: RunManager, record: RunRecord, status: RunStatus, *, error: str | None = None) -> None:
try:
await _await_completion_io(
run_manager.set_status(record.run_id, status, error=error),
run_id=record.run_id,
phase="terminal_status",
)
except TimeoutError:
# set_status updates the registry before awaiting its best-effort store.
# Keep that local outcome authoritative if persistence never returns.
record.status = status
if error is not None:
record.error = error
logger.warning("Run %s terminal status persistence timed out", record.run_id)
async def _finalize_run(*, journal: Any, run_manager: RunManager, record: RunRecord, ctx: RunContext) -> None:
"""Best-effort metadata writes after graph execution, under one budget."""
run_id = record.run_id
thread_id = record.thread_id
checkpointer = ctx.checkpointer
thread_store = ctx.thread_store
# Flush any buffered journal events and persist completion data
if journal is not None:
try:
await journal.flush()
_check_completion_cancellation()
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)
_check_completion_cancellation()
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
# Mark the thread idle before loading checkpoint/title data. Title refresh
# must never keep the composer locked after the answer stream has ended.
if thread_store is not None:
try:
if not await _completion_is_current(run_manager, record):
return
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)
if checkpointer is not None and thread_store is not None:
title_task = asyncio.create_task(
_sync_thread_title_after_run(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id=thread_id,
run_manager=run_manager,
record=record,
app_config=ctx.app_config,
),
name=f"title-sync:{thread_id}",
)
_BACKGROUND_TITLE_TASKS.add(title_task)
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
def _check_completion_cancellation() -> None:
task = asyncio.current_task()
if task is not None and task.cancelling():
# Some database adapters delay or suppress cancellation. They must not
# resume subsequent title/status writes after the cleanup budget ends.
raise asyncio.CancelledError
async def _completion_is_current(run_manager: RunManager, record: RunRecord) -> bool:
_check_completion_cancellation()
if hasattr(run_manager, "is_latest"):
return run_manager.is_latest(record)
records = await run_manager.list_by_thread(record.thread_id)
_check_completion_cancellation()
return not records or records[-1].run_id == record.run_id
# ---------------------------------------------------------------------------
# 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