mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-13 14:50:36 +00:00
* fix(agents): count each delegation once when a provider reuses its call id Tool-call ids are unique per response, not per conversation. Some providers number every response's calls from call_0, so two task calls in one run can share an id. The delegation ledger used (run_id, tool_call_id) as a delegation's identity: - extract_delegations built one id -> entry map over the whole run, so a later call_0 replaced the earlier delegation's description and took its result while it was still running. - merge_delegations replaced the same (run_id, id), keeping one entry. - SubagentLimitMiddleware counted distinct ids, so the per-run cap (subagents.max_total_per_run) never advanced past the first call_0 and a run could launch any number of subagents one response at a time. Each entry now also records dispatch_message_id, the AIMessage that issued the call, and results pair through pair_tool_call_results, which answers a call only from its own response. The reducer, DurableContext's change filter and the cap use the extended identity. An entry without a dispatch id still updates its run's entry for that call id, and a dispatch-scoped entry adopts a ledger entry written before the field existed, so runs in flight across the upgrade are not counted twice. The steer path names the dispatching AIMessage from the tool request's state, so its delegation update and the graph's own capture stay one entry. * fix(agents): close an earlier run's dangling dispatch by its own response _close_delegations_left_by_earlier_runs decided "answered" by (run_id, tool_call_id). When a stopped run had dispatched call_0 twice, the first reply kept the second, dangling entry in_progress, so the lead kept hearing "already delegated" for work nothing would finish. Entries that carry dispatch_message_id are now decided by pairing that response's calls through pair_tool_call_results. Legacy entries and dispatches no longer in history keep the id-based inference. Also pin that two runs reusing call_0 stay two ledger entries, and cross-reference the three delegation identity helpers. * fix(agents): settle an approval-gated task delegation in the ledger With `task` in `hitl_approval.tools`, the paused run records the dispatch as in_progress and the resume run executes it. The resume run opens with no HumanMessage, so its capture window starts after the pre-existing messages and never contains the dispatching AIMessage: the result was dropped and the entry stayed in_progress with no result_brief, forever. DurableContext now pairs results the resumed window receives with the last pre-existing dispatch and settles the issuing run's entry, keeping its run_id instead of re-tagging it. HITL denials of `task` carry `cancelled` subagent status, so a rejected or denied delegation settles too. * fix(agents): address review on the delegation ledger - Steer path: hold each wait's dispatch AIMessage locally and pass it to the observer, so a later response reusing the call id while the first call is still observed cannot take over its args or dispatch id. - New-turn close: a dispatch whose reply carries subagent status settles from that reply instead of staying in_progress, covering a crash between the tools checkpoint and the next capture. - Reuse thread_state.delegation_key instead of a duplicate identity helper in DurableContext. - CHANGELOG: reference #8128. * docs(changelog): drop the #8128 entry per review
616 lines
23 KiB
Python
616 lines
23 KiB
Python
import copy
|
|
import uuid
|
|
from collections.abc import Mapping, Sequence
|
|
from functools import cache
|
|
from typing import Annotated, Any, NotRequired, TypedDict, cast, get_type_hints
|
|
|
|
from langchain.agents import AgentState
|
|
from langchain_core.messages import (
|
|
AnyMessage,
|
|
BaseMessageChunk,
|
|
RemoveMessage,
|
|
convert_to_messages,
|
|
message_chunk_to_message,
|
|
)
|
|
from langgraph.channels import DeltaChannel
|
|
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
|
|
import deerflow.checkpoint_patches as _checkpoint_patches # noqa: F401 - import-time saver fixes
|
|
from deerflow.agents.goal_state import GoalOutcomeState, GoalState
|
|
from deerflow.agents.task_continuity.state import TaskNotesChannel, merge_task_notes
|
|
from deerflow.config.database_config import DEFAULT_CHECKPOINT_SNAPSHOT_FREQUENCY, CheckpointChannelMode
|
|
from deerflow.subagents.status_contract import SUBAGENT_STATUS_VALUES
|
|
|
|
|
|
def _resolve_snapshot_frequency(snapshot_frequency: int | None) -> int:
|
|
"""Resolve the effective cadence: explicit value, else process-frozen,
|
|
else default. Imported lazily — ``deerflow.runtime.__init__`` reaches this
|
|
module via ``checkpoint_state``, so a top-level import would cycle."""
|
|
if snapshot_frequency is not None:
|
|
return snapshot_frequency
|
|
from deerflow.runtime.checkpoint_mode import resolve_checkpoint_snapshot_frequency
|
|
|
|
return resolve_checkpoint_snapshot_frequency()
|
|
|
|
|
|
class SandboxState(TypedDict):
|
|
sandbox_id: NotRequired[str | None]
|
|
|
|
|
|
class ThreadDataState(TypedDict):
|
|
workspace_path: NotRequired[str | None]
|
|
uploads_path: NotRequired[str | None]
|
|
outputs_path: NotRequired[str | None]
|
|
|
|
|
|
class BackgroundTaskState(TypedDict):
|
|
task_id: str
|
|
task_name: str
|
|
status: str
|
|
updated_at: str
|
|
|
|
|
|
class BlobRefData(TypedDict):
|
|
"""JSON-safe checkpoint representation of ``deerflow.storage.BlobRef``."""
|
|
|
|
sha256: str
|
|
size: int
|
|
kind: str
|
|
content_type: NotRequired[str | None]
|
|
|
|
|
|
class ViewedImageData(TypedDict):
|
|
"""Metadata for a viewed image file.
|
|
|
|
Only lightweight metadata is persisted in checkpoint state. Image bytes are
|
|
read on-demand from the active sandbox or from a synchronized host copy whose
|
|
size and SHA-256 match the previously viewed bytes. This avoids duplicating
|
|
large base64 payloads across every checkpoint (see #4138).
|
|
"""
|
|
|
|
mime_type: str
|
|
size: int
|
|
actual_path: str
|
|
sha256: str
|
|
source_sandbox_id: NotRequired[str]
|
|
blob_ref: NotRequired[BlobRefData]
|
|
|
|
|
|
def merge_sandbox(existing: SandboxState | None, new: SandboxState | None) -> SandboxState | None:
|
|
"""Reducer for sandbox state - accepts idempotent writes only.
|
|
|
|
Multiple sandbox tools can initialize lazily in the same graph step and
|
|
emit the same sandbox_id via Command(update=...). LangGraph needs an
|
|
explicit reducer for that shared state key. Different sandbox ids in the
|
|
same thread indicate a lifecycle/isolation bug, so fail closed instead of
|
|
choosing one silently.
|
|
"""
|
|
if new is None:
|
|
return existing
|
|
if existing is None:
|
|
return new
|
|
|
|
existing_id = existing.get("sandbox_id")
|
|
new_id = new.get("sandbox_id")
|
|
if existing_id == new_id:
|
|
return existing
|
|
raise ValueError(f"Conflicting sandbox state updates: {existing_id!r} != {new_id!r}")
|
|
|
|
|
|
SandboxStateField = Annotated[NotRequired[SandboxState | None], merge_sandbox]
|
|
|
|
|
|
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
|
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
|
if existing is None:
|
|
return new or []
|
|
if new is None:
|
|
return existing
|
|
# Use dict.fromkeys to deduplicate while preserving order
|
|
return list(dict.fromkeys(existing + new))
|
|
|
|
|
|
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:
|
|
"""Reducer for viewed_images dict - merges image dictionaries.
|
|
|
|
Special case: If new is an empty dict {}, it clears the existing images.
|
|
This allows middlewares to clear the viewed_images state after processing.
|
|
|
|
Dict order tracks recency: a key updated by ``new`` is re-inserted at the
|
|
end, so a re-viewed image becomes the newest entry. The image injection
|
|
middleware admits entries newest-first and tells the model to re-view an
|
|
omitted image, so the recovery only works if the re-viewed entry actually
|
|
moves to the newest position (#6825, review on #6829).
|
|
"""
|
|
if existing is None:
|
|
return new or {}
|
|
if new is None:
|
|
return existing
|
|
# Special case: empty dict means clear all viewed images
|
|
if len(new) == 0:
|
|
return {}
|
|
# Merge dictionaries; new values override existing ones for same keys, and
|
|
# updated keys move to the end so insertion order reflects recency.
|
|
merged = {key: value for key, value in existing.items() if key not in new}
|
|
merged.update(new)
|
|
return merged
|
|
|
|
|
|
def merge_todos(existing: list | None, new: list | None) -> list | None:
|
|
"""Reducer for todos list - keeps the last non-None value.
|
|
|
|
Semantics:
|
|
- If `new` is None (node didn't touch todos), preserve `existing`.
|
|
- If `new` is provided (even empty list), it represents an explicit
|
|
update and wins over `existing`.
|
|
"""
|
|
if new is None:
|
|
return existing
|
|
return new
|
|
|
|
|
|
def merge_goal(existing: GoalState | None, new: GoalState | None) -> GoalState | None:
|
|
"""Reducer for goal state - preserves existing when a node does not touch it."""
|
|
if new is None:
|
|
return existing
|
|
return new
|
|
|
|
|
|
class PromotedTools(TypedDict):
|
|
catalog_hash: str
|
|
names: list[str]
|
|
turn_id: NotRequired[str]
|
|
|
|
|
|
def merge_promoted(existing: PromotedTools | None, new: PromotedTools | None) -> PromotedTools | None:
|
|
"""Reducer for deferred-tool promotions, scoped by catalog and user turn.
|
|
|
|
- new None/empty -> preserve existing (node didn't touch promotions).
|
|
- catalog_hash or explicit turn_id changed -> replace wholesale, dropping
|
|
stale names.
|
|
- same scope -> union names, dedupe, preserve order.
|
|
- a missing new turn_id inherits the existing scope. ``tool_search`` writes
|
|
this shape after the filter establishes the current turn before the model.
|
|
"""
|
|
if not new:
|
|
return existing
|
|
new_turn_id = new.get("turn_id")
|
|
replace = existing is None or existing.get("catalog_hash") != new["catalog_hash"] or (new_turn_id is not None and existing.get("turn_id") != new_turn_id)
|
|
existing_names = existing.get("names") if existing is not None else None
|
|
if not isinstance(existing_names, list) or not all(isinstance(name, str) for name in existing_names):
|
|
existing_names = []
|
|
names = new["names"] if replace else existing_names + new["names"]
|
|
merged: PromotedTools = {
|
|
"catalog_hash": new["catalog_hash"],
|
|
"names": list(dict.fromkeys(names)),
|
|
}
|
|
turn_id = new_turn_id if new_turn_id is not None else (existing or {}).get("turn_id")
|
|
if turn_id is not None:
|
|
merged["turn_id"] = turn_id
|
|
return merged
|
|
|
|
|
|
TERMINAL_STATUSES: frozenset[str] = frozenset(SUBAGENT_STATUS_VALUES)
|
|
_DELEGATION_LEDGER_MAX_ENTRIES = 50
|
|
|
|
|
|
class DelegationEntry(TypedDict):
|
|
id: str
|
|
run_id: NotRequired[str]
|
|
description: str
|
|
subagent_type: str
|
|
status: str
|
|
result_brief: NotRequired[str]
|
|
result_sha256: NotRequired[str]
|
|
result_ref: NotRequired[str]
|
|
# Why a guardrail cap ended the run early (#3875 Phase 2): token_capped /
|
|
# turn_capped / loop_capped. The status stays completed/failed; this field
|
|
# is the additive signal that distinguishes a capped run from a clean one.
|
|
stop_reason: NotRequired[str]
|
|
# RFC #4651 PR2: parent-side citation-check verdict (advisory execution
|
|
# evidence), stamped at task write-back; absent on legacy history.
|
|
receipt_verdict: NotRequired[dict]
|
|
# RFC #4651 PR4: deterministic acceptance-checklist verdict, same
|
|
# provenance as receipt_verdict.
|
|
acceptance_verdict: NotRequired[dict]
|
|
created_at: str
|
|
# The AIMessage that issued the call. Tool-call ids are unique per response,
|
|
# so a provider can reuse one in a later response of the same run; this keeps
|
|
# each dispatch its own entry. Absent on entries written before it existed.
|
|
dispatch_message_id: NotRequired[str]
|
|
|
|
|
|
type DelegationKey = tuple[str | None, str, str | None]
|
|
|
|
|
|
def delegation_key(entry: Mapping[str, Any]) -> DelegationKey:
|
|
"""Identify one delegation: ``(run_id, tool-call id, dispatch message id)``.
|
|
|
|
DurableContext uses this key too. The cap's ``_delegation_id`` reads the
|
|
same fields from entries; keep the two in step.
|
|
"""
|
|
return entry.get("run_id") or None, entry["id"], entry.get("dispatch_message_id") or None
|
|
|
|
|
|
def find_delegation_key(keys: Sequence[DelegationKey], key: DelegationKey) -> DelegationKey | None:
|
|
"""Return the key in ``keys`` that an entry identified by ``key`` updates, or None.
|
|
|
|
An exact match wins. Otherwise an entry without a run id updates the most
|
|
recent entry with its call id, as updates did before run-scoped
|
|
identities; an entry without a dispatch id updates the most recent entry
|
|
of its run with that call id; and a dispatch-scoped entry adopts the most
|
|
recent entry of its run and call id that predates dispatch ids. Two
|
|
different dispatches never match each other.
|
|
"""
|
|
if key in keys:
|
|
return key
|
|
run_id, entry_id, dispatch_id = key
|
|
for prior in reversed(keys):
|
|
if prior[1] != entry_id:
|
|
continue
|
|
if run_id is None:
|
|
return prior
|
|
if prior[0] == run_id and (dispatch_id is None or prior[2] is None):
|
|
return prior
|
|
return None
|
|
|
|
|
|
def merge_delegations(existing: list[DelegationEntry] | None, new: list[DelegationEntry] | None) -> list[DelegationEntry]:
|
|
"""Reducer for the delegation ledger.
|
|
|
|
- new None/empty -> preserve existing.
|
|
- append entries, replacing the same delegation (see ``find_delegation_key``)
|
|
with the latest version while preserving first-seen order. Provider ids
|
|
can repeat in later runs of the same thread and in later responses of
|
|
the same run.
|
|
- terminal status is never overwritten by a non-terminal status.
|
|
"""
|
|
if not new:
|
|
return existing or []
|
|
|
|
by_key: dict[DelegationKey, DelegationEntry] = {}
|
|
order: list[DelegationKey] = []
|
|
for entry in [*(existing or []), *new]:
|
|
target = find_delegation_key(order, delegation_key(entry))
|
|
previous = by_key.get(target) if target is not None else None
|
|
if previous is None:
|
|
key = delegation_key(entry)
|
|
order.append(key)
|
|
by_key[key] = entry
|
|
continue
|
|
if previous["status"] in TERMINAL_STATUSES and entry["status"] not in TERMINAL_STATUSES:
|
|
continue
|
|
inherited = {field: previous[field] for field in ("created_at", "run_id", "dispatch_message_id") if previous.get(field) and (field == "created_at" or not entry.get(field))}
|
|
if inherited:
|
|
entry = {**entry, **inherited}
|
|
key = delegation_key(entry)
|
|
if key != target:
|
|
# A legacy entry gained its dispatch id: re-key it in place so a
|
|
# later dispatch reusing the call id starts its own entry.
|
|
order[order.index(target)] = key
|
|
del by_key[target]
|
|
by_key[key] = entry
|
|
merged = [by_key[key] for key in order]
|
|
if len(merged) > _DELEGATION_LEDGER_MAX_ENTRIES:
|
|
merged = merged[-_DELEGATION_LEDGER_MAX_ENTRIES:]
|
|
return merged
|
|
|
|
|
|
_SKILL_CONTEXT_MAX_ENTRIES = 8
|
|
_SKILL_DESCRIPTION_MAX_CHARS = 500
|
|
|
|
|
|
class SkillEntry(TypedDict):
|
|
name: str
|
|
path: str
|
|
description: str
|
|
loaded_at: int
|
|
|
|
|
|
def _normalize_skill_entry(entry: Mapping[str, object]) -> SkillEntry:
|
|
"""Drop legacy payload keys before storing skill_context back to state."""
|
|
description = entry.get("description")
|
|
loaded_at = entry.get("loaded_at")
|
|
return {
|
|
"name": str(entry.get("name") or ""),
|
|
"path": str(entry["path"]),
|
|
"description": " ".join(description.split())[:_SKILL_DESCRIPTION_MAX_CHARS] if isinstance(description, str) else "",
|
|
"loaded_at": loaded_at if isinstance(loaded_at, int) else 0,
|
|
}
|
|
|
|
|
|
def merge_skill_context(existing: list[SkillEntry] | None, new: list[SkillEntry] | None) -> list[SkillEntry]:
|
|
"""Reducer for the skill-context channel.
|
|
|
|
- new None/empty -> preserve existing.
|
|
- legacy entries are normalized to references; verbatim body keys are dropped.
|
|
- dedup by ``path``; later reads refresh recency and replace the reference.
|
|
- cap by keeping the most recently read entries. ``loaded_at`` is
|
|
observational only because message indices reset after compaction.
|
|
"""
|
|
normalized_existing = [_normalize_skill_entry(entry) for entry in existing or []]
|
|
if not new:
|
|
return normalized_existing
|
|
|
|
by_path: dict[str, SkillEntry] = {}
|
|
order: list[str] = []
|
|
for entry in normalized_existing:
|
|
path = entry["path"]
|
|
if path not in by_path:
|
|
order.append(path)
|
|
by_path[path] = entry
|
|
|
|
for entry in (_normalize_skill_entry(entry) for entry in new):
|
|
path = entry["path"]
|
|
if path in by_path:
|
|
order.remove(path)
|
|
order.append(path)
|
|
by_path[path] = entry
|
|
|
|
merged = [by_path[path] for path in order]
|
|
if len(merged) > _SKILL_CONTEXT_MAX_ENTRIES:
|
|
merged = merged[-_SKILL_CONTEXT_MAX_ENTRIES:]
|
|
return merged
|
|
|
|
|
|
# Absolute ceiling applied by the reducer itself. The operator-configured cap
|
|
# (`tool_artifacts.max_entries`) is enforced per-agent by
|
|
# ArtifactCaptureMiddleware when it emits updates, so two agents in one process
|
|
# can carry different caps without sharing state.
|
|
_ARTIFACT_MAX_ENTRIES_CEILING = 1000
|
|
|
|
|
|
class ArtifactEntry(TypedDict):
|
|
"""A captured artifact reference from a tool result (issue #4676).
|
|
|
|
Stored in ``ThreadState.tool_artifacts`` so it survives context compaction.
|
|
The model sees only the short ``handle``; the real reference (path, URL,
|
|
task id) lives here and is resolved at tool-call time.
|
|
"""
|
|
|
|
handle: str
|
|
tool_name: str
|
|
tool_call_id: str
|
|
call_index: int
|
|
artifact_type: str
|
|
display_name: str
|
|
real_ref: str
|
|
mime_type: NotRequired[str]
|
|
created_at: str
|
|
consumed_by: NotRequired[list[str]]
|
|
|
|
|
|
def merge_tool_artifacts(existing: list[ArtifactEntry] | None, new: list[ArtifactEntry] | None) -> list[ArtifactEntry]:
|
|
"""Reducer for the tool-artifact registry channel.
|
|
|
|
- new None/empty -> preserve existing.
|
|
- append entries, replacing same handle with the latest version while
|
|
preserving first-seen order (latest wins, e.g. for consumption updates).
|
|
- a trailing ``{"op": "trim_to", "keep": N}`` directive (emitted by the
|
|
capture middleware) makes the configured cap a sliding window: the
|
|
oldest entries beyond N are evicted. Without a directive nothing is
|
|
evicted, so updates stay purely additive.
|
|
- absolute ceiling of 1000 applies regardless.
|
|
"""
|
|
if not new:
|
|
return existing or []
|
|
|
|
by_handle: dict[str, ArtifactEntry] = {}
|
|
order: list[str] = []
|
|
for entry in [*(existing or []), *new]:
|
|
if not isinstance(entry, dict) or "handle" not in entry:
|
|
continue
|
|
handle = entry["handle"]
|
|
if handle not in by_handle:
|
|
order.append(handle)
|
|
by_handle[handle] = entry
|
|
|
|
merged = [by_handle[handle] for handle in order]
|
|
|
|
for item in reversed(new):
|
|
if isinstance(item, dict) and item.get("op") == "trim_to":
|
|
keep = min(int(item.get("keep", len(merged))), _ARTIFACT_MAX_ENTRIES_CEILING)
|
|
if keep < len(merged):
|
|
merged = merged[-keep:]
|
|
break
|
|
|
|
if len(merged) > _ARTIFACT_MAX_ENTRIES_CEILING:
|
|
merged = merged[-_ARTIFACT_MAX_ENTRIES_CEILING:]
|
|
return merged
|
|
|
|
|
|
class ThreadState(AgentState):
|
|
sandbox: SandboxStateField
|
|
thread_data: NotRequired[ThreadDataState | None]
|
|
title: NotRequired[str | None]
|
|
artifacts: Annotated[list[str], merge_artifacts]
|
|
todos: Annotated[list | None, merge_todos]
|
|
goal: Annotated[GoalState | None, merge_goal]
|
|
goal_outcome: NotRequired[GoalOutcomeState | None]
|
|
applied_run_inputs: NotRequired[dict | None]
|
|
uploaded_files: NotRequired[list[dict] | None]
|
|
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> metadata (no base64)
|
|
promoted: Annotated[PromotedTools | None, merge_promoted]
|
|
delegations: Annotated[list[DelegationEntry], merge_delegations]
|
|
skill_context: Annotated[list[SkillEntry], merge_skill_context]
|
|
tool_artifacts: Annotated[list[ArtifactEntry], merge_tool_artifacts]
|
|
tool_artifact_processed: Annotated[list[str], merge_artifacts]
|
|
task_notes: Annotated[dict | None, TaskNotesChannel(dict | None, merge_task_notes)]
|
|
task_history: NotRequired[dict | None]
|
|
summary_text: NotRequired[str | None]
|
|
background_tasks: NotRequired[list[BackgroundTaskState]]
|
|
|
|
|
|
def _normalize_messages(value: Any) -> list[AnyMessage]:
|
|
values = value if isinstance(value, list) else [value]
|
|
messages = [message_chunk_to_message(cast(BaseMessageChunk, message)) for message in convert_to_messages(values)]
|
|
for message in messages:
|
|
if message.id is None:
|
|
message.id = str(uuid.uuid4())
|
|
return messages
|
|
|
|
|
|
def _index_messages(
|
|
messages: list[AnyMessage | None],
|
|
) -> tuple[dict[str, int], dict[str, list[int]]]:
|
|
latest_position: dict[str, int] = {}
|
|
positions_by_id: dict[str, list[int]] = {}
|
|
for position, message in enumerate(messages):
|
|
if message is None:
|
|
continue
|
|
message_id = cast(str, message.id)
|
|
latest_position[message_id] = position
|
|
positions_by_id.setdefault(message_id, []).append(position)
|
|
return latest_position, positions_by_id
|
|
|
|
|
|
def _raise_null_write(has_messages: bool) -> None:
|
|
# ``add_messages(left, None)`` reports only ``left`` when the accumulated
|
|
# message list is non-empty; with an empty list, it reports only ``right``.
|
|
received = "left" if has_messages else "right"
|
|
raise ValueError(f"Must specify non-null arguments for both 'left' and 'right'. Only received: '{received}'.")
|
|
|
|
|
|
def merge_message_writes(state: list[AnyMessage], writes: Sequence[Any]) -> list[AnyMessage]:
|
|
"""Fold DeltaChannel writes with ``add_messages`` semantics in linear time.
|
|
|
|
LangGraph's private ``_messages_delta_reducer`` is also linear, but does
|
|
not preserve the public reducer's full coercion, ID, removal, and
|
|
``REMOVE_ALL_MESSAGES`` behavior.
|
|
"""
|
|
if not writes:
|
|
return list(state)
|
|
if writes[0] is None:
|
|
_raise_null_write(bool(state))
|
|
|
|
messages: list[AnyMessage | None] = _normalize_messages(state)
|
|
latest_position, positions_by_id = _index_messages(messages)
|
|
|
|
for write in writes:
|
|
if write is None:
|
|
_raise_null_write(bool(latest_position))
|
|
normalized_write = _normalize_messages(write)
|
|
remove_all_idx = None
|
|
for position, message in enumerate(normalized_write):
|
|
if isinstance(message, RemoveMessage) and message.id == REMOVE_ALL_MESSAGES:
|
|
remove_all_idx = position
|
|
|
|
if remove_all_idx is not None:
|
|
messages = list(normalized_write[remove_all_idx + 1 :])
|
|
latest_position, positions_by_id = _index_messages(messages)
|
|
continue
|
|
|
|
ids_to_remove: set[str] = set()
|
|
for message in normalized_write:
|
|
message_id = cast(str, message.id)
|
|
existing_position = latest_position.get(message_id)
|
|
if existing_position is not None:
|
|
if isinstance(message, RemoveMessage):
|
|
ids_to_remove.add(message_id)
|
|
else:
|
|
ids_to_remove.discard(message_id)
|
|
messages[existing_position] = message
|
|
continue
|
|
|
|
if isinstance(message, RemoveMessage):
|
|
raise ValueError(f"Attempting to delete a message with an ID that doesn't exist ('{message_id}')")
|
|
|
|
position = len(messages)
|
|
messages.append(message)
|
|
latest_position[message_id] = position
|
|
positions_by_id[message_id] = [position]
|
|
|
|
for message_id in ids_to_remove:
|
|
for position in positions_by_id.pop(message_id):
|
|
messages[position] = None
|
|
del latest_position[message_id]
|
|
|
|
return [message for message in messages if message is not None]
|
|
|
|
|
|
def delta_messages_field(snapshot_frequency: int = DEFAULT_CHECKPOINT_SNAPSHOT_FREQUENCY) -> Any:
|
|
"""Messages field annotation with a ``DeltaChannel`` at the given cadence."""
|
|
return Annotated[
|
|
list[AnyMessage],
|
|
DeltaChannel(merge_message_writes, snapshot_frequency=snapshot_frequency),
|
|
]
|
|
|
|
|
|
DELTA_MESSAGES_FIELD = delta_messages_field()
|
|
|
|
|
|
class DeltaThreadState(ThreadState):
|
|
messages: DELTA_MESSAGES_FIELD
|
|
|
|
|
|
THREAD_STATE_REDUCER_FIELDS = frozenset(
|
|
{
|
|
"messages",
|
|
"sandbox",
|
|
"artifacts",
|
|
"todos",
|
|
"goal",
|
|
"viewed_images",
|
|
"promoted",
|
|
"delegations",
|
|
"skill_context",
|
|
"tool_artifacts",
|
|
"tool_artifact_processed",
|
|
"task_notes",
|
|
}
|
|
)
|
|
|
|
|
|
def get_thread_state_schema(mode: CheckpointChannelMode, snapshot_frequency: int | None = None) -> type:
|
|
if mode != "delta":
|
|
return ThreadState
|
|
return _delta_thread_state_schema(_resolve_snapshot_frequency(snapshot_frequency))
|
|
|
|
|
|
@cache
|
|
def _delta_thread_state_schema(snapshot_frequency: int) -> type:
|
|
"""Delta thread schema keyed by cadence; the default keeps the static
|
|
``DeltaThreadState`` identity so existing type checks keep holding."""
|
|
if snapshot_frequency == DEFAULT_CHECKPOINT_SNAPSHOT_FREQUENCY:
|
|
return DeltaThreadState
|
|
annotations = get_type_hints(ThreadState, include_extras=True)
|
|
annotations["messages"] = delta_messages_field(snapshot_frequency)
|
|
return TypedDict(
|
|
f"DeltaThreadState_f{snapshot_frequency}",
|
|
annotations,
|
|
total=getattr(ThreadState, "__total__", True),
|
|
)
|
|
|
|
|
|
def adapt_state_schema_for_mode(schema: type, mode: CheckpointChannelMode, snapshot_frequency: int | None = None) -> type:
|
|
if mode == "full":
|
|
return schema
|
|
return _adapt_state_schema_for_delta(schema, _resolve_snapshot_frequency(snapshot_frequency))
|
|
|
|
|
|
@cache
|
|
def _adapt_state_schema_for_delta(schema: type, snapshot_frequency: int) -> type:
|
|
annotations = get_type_hints(schema, include_extras=True)
|
|
annotations["messages"] = delta_messages_field(snapshot_frequency)
|
|
return TypedDict(
|
|
f"Delta{schema.__module__.replace('.', '_')}_{schema.__name__}_f{snapshot_frequency}",
|
|
annotations,
|
|
total=getattr(schema, "__total__", True),
|
|
)
|
|
|
|
|
|
def normalize_middleware_state_schemas(middleware: Sequence[Any], mode: CheckpointChannelMode, snapshot_frequency: int | None = None) -> list[Any]:
|
|
if mode == "full":
|
|
return list(middleware)
|
|
resolved_frequency = _resolve_snapshot_frequency(snapshot_frequency)
|
|
normalized = []
|
|
for item in middleware:
|
|
schema = getattr(item, "state_schema", None)
|
|
if schema is None:
|
|
normalized.append(item)
|
|
continue
|
|
adapted = copy.copy(item)
|
|
adapted.state_schema = adapt_state_schema_for_mode(schema, mode, resolved_frequency)
|
|
normalized.append(adapted)
|
|
return normalized
|