deer-flow/backend/app/gateway/checkpoint_lineage.py
Aari 9a43d8276d
fix(gateway): replay edit and rerun from a settled checkpoint (#4534)
Editing the only turn of a thread reran the original prompt: the model
answered the question the edit was replacing while the UI showed the
edited text, and the edit vanished on reload.

The replay-base lookup decided whether a checkpoint predates the target
user message by message id alone. DynamicContextMiddleware re-keys the
first user turn to `{id}__user` mid-run, so every checkpoint written
before it holds the same prompt under an id the lookup cannot match. The
scan walked past those and anchored inside the run that produced the
turn — a checkpoint that still contains the original prompt and owns the
injection node's pending writes, which the replay then re-added after the
edited message.

Require the replay base to be a settled checkpoint (no pending tasks) in
both the lineage walk and the chronological fallback. That rule is
middleware agnostic: the first turn now anchors on the thread's empty
initial checkpoint and later turns on the previous run's tail, which also
drops the existing reliance on LangGraph discarding a stale `__start__`
write.

Edit replay additionally passes `head_checkpoint` so it resolves its base
lineage-first like regenerate does, and a replayed user message is
restored to its pre-swap id: replaying `{id}__user` into a state that has
no reminder yet makes the middleware treat the turn as already injected
and silently drops its date and memory block.

Frontend: a prepared replay masks the turn it supersedes, so the
optimistic-message baseline is taken from the post-mask human count. The
pre-mask count can never be exceeded when the replay puts exactly one
human message back, and on the first turn the runtime re-keys the
replacement message so identity comparison cannot stand in for the count.

Fixes #4531
2026-07-28 22:12:27 +08:00

184 lines
7.9 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.

"""Shared helpers for resolving replay checkpoints on one checkpoint lineage."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
class CheckpointLineageError(RuntimeError):
"""Raised when a requested checkpoint ancestor cannot be resolved safely."""
class CheckpointParentMissingError(CheckpointLineageError):
"""Raised when a legacy checkpoint does not record its parent link."""
class CheckpointLineageIntegrityError(CheckpointLineageError):
"""Raised when recorded checkpoint lineage is present but unsafe to use."""
def checkpoint_messages(checkpoint_tuple: Any) -> list[Any]:
values = getattr(checkpoint_tuple, "values", None)
if isinstance(values, dict):
messages = values.get("messages", [])
return list(messages) if isinstance(messages, list) else []
checkpoint = getattr(checkpoint_tuple, "checkpoint", None) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
messages = channel_values.get("messages", []) if isinstance(channel_values, dict) else []
return list(messages) if isinstance(messages, list) else []
def checkpoint_configurable(checkpoint_tuple: Any) -> dict[str, Any]:
config = getattr(checkpoint_tuple, "config", None) or {}
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
return dict(configurable) if isinstance(configurable, dict) else {}
def checkpoint_metadata(checkpoint_tuple: Any) -> dict[str, Any]:
metadata = getattr(checkpoint_tuple, "metadata", None) or {}
return dict(metadata) if isinstance(metadata, dict) else {}
def is_duration_only_checkpoint(checkpoint_tuple: Any) -> bool:
writes = checkpoint_metadata(checkpoint_tuple).get("writes")
return isinstance(writes, dict) and "runtime_run_duration" in writes
def has_pending_tasks(checkpoint_tuple: Any) -> bool:
"""Return whether *checkpoint_tuple* still has graph work scheduled.
A replay base must be a state the thread was at rest in. A mid-run
checkpoint owns the writes of the node that was about to run, and resuming
from it replays them — re-adding the very turn the replay is meant to
replace. Message ids alone cannot detect this, because middleware may
rewrite a message's id in the same run that produced it.
``next`` is not derivable on the degraded raw-checkpoint read path, which
reports no tasks at all. Absence of evidence therefore stays permissive:
those reads keep selecting the same base they always did.
"""
return bool(getattr(checkpoint_tuple, "next", None))
def _message_id(message: Any) -> str | None:
value = getattr(message, "id", None)
if value is None and isinstance(message, dict):
value = message.get("id")
return str(value) if value else None
def _config_identity(config: dict[str, Any]) -> tuple[str, str, str] | None:
configurable = config.get("configurable", {})
thread_id = configurable.get("thread_id")
checkpoint_ns = configurable.get("checkpoint_ns", "")
checkpoint_id = configurable.get("checkpoint_id")
if not isinstance(thread_id, str) or not thread_id or not isinstance(checkpoint_id, str) or not checkpoint_id:
return None
return thread_id, str(checkpoint_ns or ""), checkpoint_id
def _checkpoint_identity(checkpoint_tuple: Any) -> tuple[str, str, str] | None:
return _config_identity(getattr(checkpoint_tuple, "config", {}) or {})
def _checkpoint_exists(checkpoint_tuple: Any) -> bool:
"""Distinguish a persisted empty checkpoint from an accessor miss.
LangGraph represents a missing explicit ``checkpoint_id`` as an empty
snapshot that echoes the requested config. Persisted snapshots always
carry metadata, a creation timestamp, or a raw checkpoint payload.
"""
explicit = getattr(checkpoint_tuple, "checkpoint_exists", None)
if isinstance(explicit, bool):
return explicit
if getattr(checkpoint_tuple, "metadata", None) is not None:
return True
if getattr(checkpoint_tuple, "created_at", None) is not None:
return True
return isinstance(getattr(checkpoint_tuple, "checkpoint", None), dict)
async def find_checkpoint_before_message(
accessor: Any,
head_checkpoint: Any,
message_id: str,
*,
max_depth: int,
) -> Any:
"""Walk one parent lineage and return the first checkpoint before ``message_id``.
Following ``parent_config`` is important after a regenerate: a thread can contain
sibling checkpoint branches, and a global time-ordered scan can otherwise select
a checkpoint from the wrong branch. Duration-only metadata checkpoints do not
represent an addressable conversation state and are skipped, and so are
checkpoints that still have pending tasks (see :func:`has_pending_tasks`).
"""
if message_id not in {_message_id(message) for message in checkpoint_messages(head_checkpoint)}:
raise CheckpointLineageIntegrityError("Target message is not present in the checkpoint head")
current = head_checkpoint
visited: set[tuple[str, str, str]] = set()
current_identity = _checkpoint_identity(current)
if current_identity is not None:
visited.add(current_identity)
# Each step performs one ancestor read, but normal branch/regenerate
# histories cross the target boundary within 13 reads. Keep max_depth as
# a conservative safety cap for valid histories with many intermediate or
# duration-only checkpoints.
for _ in range(max_depth):
parent_config = getattr(current, "parent_config", None)
if not isinstance(parent_config, dict):
raise CheckpointParentMissingError("Checkpoint lineage ended before the target message")
parent = await accessor.aget(parent_config)
parent_identity = _checkpoint_identity(parent)
requested_parent_identity = _config_identity(parent_config)
if parent_identity is None or not _checkpoint_exists(parent) or (requested_parent_identity is not None and parent_identity != requested_parent_identity):
raise CheckpointLineageIntegrityError("Checkpoint parent link is not addressable")
if parent_identity is not None:
if parent_identity in visited:
raise CheckpointLineageIntegrityError("Checkpoint lineage contains a cycle")
visited.add(parent_identity)
if is_duration_only_checkpoint(parent):
current = parent
continue
parent_message_ids = {_message_id(message) for message in checkpoint_messages(parent)}
if message_id not in parent_message_ids and not has_pending_tasks(parent):
return parent
current = parent
raise CheckpointLineageIntegrityError(f"Checkpoint lineage exceeded the scan limit ({max_depth})")
def find_checkpoint_before_message_chronologically(
checkpoints: Sequence[Any],
message_id: str,
) -> tuple[Any | None, bool]:
"""Return ``(replay_base, target_found)`` from newest-first history.
This is a compatibility fallback for imported or legacy checkpoints that do
not carry ``parent_config`` links. Callers must prefer the lineage walk when
links are available because a chronological scan cannot distinguish sibling
checkpoint branches. Duration-only checkpoints are ignored, and only settled
checkpoints (see :func:`has_pending_tasks`) with an addressable id can become
the replay base.
"""
previous_checkpoint = None
for checkpoint_tuple in reversed(checkpoints):
if is_duration_only_checkpoint(checkpoint_tuple):
continue
message_ids = {_message_id(message) for message in checkpoint_messages(checkpoint_tuple)}
if message_id in message_ids:
return previous_checkpoint, True
if checkpoint_configurable(checkpoint_tuple).get("checkpoint_id") and not has_pending_tasks(checkpoint_tuple):
previous_checkpoint = checkpoint_tuple
return None, False