Zeren Wang a06a6fed7e
feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2) (#5109)
* feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2)

PR4 of RFC #4651: check lead-supplied acceptance_criteria in code when a
subagent completes, so objectively checkable requirements can never be
silently passed by a self-report.

- subagents/acceptance_checks.py: deterministic leaf families —
  file:<path> exists|non-empty and file_written:<path> read through
  read_current_file_content scoped to the shared thread workspace; the
  read uses the sandbox-native virtual path form (the local read
  validator and provider mount tables resolve /mnt/user-data/... paths,
  not host paths); the scope decision canonicalizes with realpath on the
  local sandbox so workspace symlinks cannot escape into uploads; a
  remote provider's "Error: ..." return string is normalized to a
  failed check (provider-typed via is_local_sandbox); a
  UnicodeDecodeError marks a binary deliverable as existing and
  non-empty; out-of-scope paths degrade to UNVERIFIED.
  tests_passed:<command> anchors to a matching recorded bash execution
  with status=success and a test-summary shape; matching is
  shell-structure aware with control-flow attribution (span must end at
  the last segment with provable execution), negating-option values are
  ineligible evidence and a target negated anywhere in the command
  degrades the match, extra flags must be selection-preserving, extra
  positionals widen only after a path-scoped criterion, truncated
  commands degrade via command_truncated, the summary shape is read
  only from output attributable to the matched segment (preceding
  segments provably silent by invocation form), and pass shapes require
  a nonzero passed count. Criterion text is neutralized with
  neutralize_untrusted_tags before storage/rendering. Anything else
  renders UNVERIFIED, never silently passed.
- executor: accumulate bounded bash command/output evidence per streamed
  chunk (merged by tool_call_id, newest-capped) so subagent
  summarization compacting earlier messages cannot erase a recorded
  execution; the recorded status is the actual shell exit status parsed
  from the output's exit marker (signed codes included; the remote
  Command exited with code N form is accepted only as the whole trimmed
  output), falling back to deerflow_tool_meta only when no marker
  exists.
- sandbox providers: e2b/opensandbox/tenki/boxlite append the
  LocalSandbox-style "Exit Code: N" marker on nonzero exit even with
  non-empty output; aio propagates the SDK's structured exit_code on
  both exec paths the same way; local timeouts append Exit Code: 124;
  and _truncate_bash_output always preserves a trailing exit marker
  (signed included) inside its budget, with a 32-char floor raising any
  smaller configured limit, so the actual shell outcome always survives
  in the output text.
- task_tool: run the checklist offloaded (asyncio.to_thread) on the
  completed branch, failure-isolated; stamp the verdict into result
  metadata and render the per-criterion section into the model-visible
  result text.
- status contract: additive subagent_acceptance_verdict transport with
  read-side structural validation.
- delegation ledger: entry carries the verdict and renders a compact
  acceptance segment; gateway strips caller-forged verdicts from both
  ledger entries and message metadata, like the citation verdict.
- blocking-IO anchor pins the offload (teeth proven red->green); leaf
  read errors catch only OSError/SandboxError so unexpected errors reach
  the task-tool-level isolation instead of being mislabeled.

* fix(harness): close acceptance evidence gaps from review (RFC #4651 PR4)

- negating options: overlap with a matched criterion target is now
  checked by path/nodeid prefix, not exact token equality — excluding a
  sub-path of the criterion's selection (pytest tests --deselect
  tests/unit/test_auth.py) degrades to UNVERIFIED instead of holds
- output attribution: any redirection token in the matched final segment
  makes the recorded tail non-attributable (> / >> / 2> are word
  characters to the parser, so redirection was invisible to the matcher)
- silent-source allowlist narrowed from any *activate suffix to the
  */bin/activate shape
- status_contract docstring: restore the shared-fixture sentence and
  note subagent_acceptance_verdict is deliberately outside the fixture
- executor: update_bash_executions publishes [] (stream carried no
  bash-family calls) instead of collapsing it into None, mirroring
  update_tool_receipts

* fix(harness): close acceptance residual gaps from re-review (RFC #4651 PR4)

- tests_passed: add error outcomes to the fail shapes — "4 passed, 1 error"
  and pytest's "ERROR <nodeid>" short summary no longer satisfy the pass
  shape when the exit status is swallowed (|| true) or absent; zero-error
  counts stay clean.
- file leaves: bound the deliverable read — a "wc -c" shell size probe
  answers files above 50k bytes without loading ~2x their size, honoring
  the host-bash kill switch and falling back to the full read on any
  non-integer rendering, so verdicts never get less sound.
- executor: record the exit marker text as status_marker on harvested bash
  evidence; the leaf detail now reports the marker actually seen instead of
  asserting a failure indistinguishable from the command's own trailing text.
- extend the blocking-IO anchor to drive the probe branch inside the
  offload; teeth re-verified red->green.

* fix(harness): close acceptance forgery and bound gaps from P2 re-review (RFC #4651 PR4)

- file leaves: never read unbounded — size is established first (os.stat on
  the validated local host path, so the host-bash-disabled configuration
  needs no shell; a guarded wc -c on remote providers that renders
  missing/unreadable in its own words). Above the 50k cap the leaf answers
  from the size alone, at/below it the full read runs, and an
  unestablishable size degrades to UNVERIFIED instead of an unlimited
  fallback read.
- output attribution: source/. prefixes are never provably silent — a
  crafted */bin/activate path shape says nothing about what the script
  prints, so sourced segments can no longer lend a passing summary.
- executable identity: an explicitly path-spelled criterion now requires
  the same normalized executable path; the basename rule stays only for
  deliberately bare criterion commands.

* fix(harness): run acceptance size probe outside subagent-controlled state (RFC #4651 PR4)

- remote probe no longer runs in the sandbox's persistent shell: a fresh
  env -i /bin/sh with absolute-path stat/realpath (poisoned functions,
  aliases, PATH, exported functions, IFS, locale cannot steer it), plus a
  marker env routing AIO onto a fresh per-call bash.exec session.
- metadata-only: stat never opens content, so a FIFO deliverable cannot
  block the parent for the provider's idle timeout; non-regular files
  (fifo/dir/symlink) degrade to UNVERIFIED.
- containment canonicalized against the literal mount root: a
  final-component symlink or a swapped parent directory (root included)
  cannot redirect the check outside shared storage; unprovable layouts
  degrade to UNVERIFIED.

* fix(harness): canonicalize probe containment against the canonical mount root (RFC #4651 PR4)

Literal-root equality made every remote file leaf permanently UNVERIFIED
on e2b and Tenki, which realize /mnt/user-data as a symlink to the home
dir by default (e2b bootstrap 'sudo ln -sfn', Tenki best-effort symlink).
Containment now compares the file's realpath against the mount root's
realpath — exactly what the provider's own read path resolves, so probe
and read-back stay consistent; final-component symlinks stay rejected by
the non-dereferencing stat, and an intermediate dir-link escape under a
sane root still lands ESCAPED. The inner script is a module constant and
the suite now executes the composed probe for real against on-disk
layouts (real dir, symlinked prefix, final symlink, fifo, missing,
dir-link escape), which the canned-output stub could not see.

* fix(harness): close bare-criterion negation and CDPATH summary channels (RFC #4651 PR4)

- matching: a criterion with no positional selection target (bare pytest,
  make test) stands for the runner's default selection, so ANY negating
  option (--ignore/--deselect/...) makes the recorded run a different
  selection — unprovable. The overlap guard only sees consumed criterion
  tokens, which a bare criterion does not have; scoped criteria keep the
  unrelated-exclusion behavior.
- attribution: cd is no longer blanket-silent — CDPATH makes cd print the
  resolved (subagent-chosen) destination and the pass shapes match as
  substrings, so one mkdir 'all tests passed' plus an export minted a pass
  for any quiet command. A cd argument or CDPATH= value (export or leading
  assignment) carrying any summary shape makes the segment non-silent;
  shape-free cd dir wrappers keep matching.
- docs: _truncate_bash_output states the effective 32-char floor (the
  guarantee previously read as an unconditional max_chars bound).

* fix(harness): close env-assignment and expansion channels in acceptance matching (RFC #4651 PR4)

Self-audit in the shape of the last review rounds — channels the matcher
classified as accounted-for that can change what runs, narrow the
selection, or lend the summary text:

- env assignments are no longer blanket-stripped: only an allowlist of
  inert display/CI knobs (CI, NO_COLOR, PY_COLORS, ...) may prefix a
  matched span, and a non-allowlisted assignment in any preceding segment
  (pure-assignment or export NAME=) is state pollution — PATH redirects
  the executable, LD_PRELOAD/PYTHONPATH/NODE_OPTIONS inject code,
  PYTEST_ADDOPTS/GOFLAGS/MAKEFILES inject selection-changing inputs,
  BASH_ENV runs arbitrary shell startup. All degrade to unprovable.
- runtime expansions: any span token carrying /$( )/backticks, any
  negating-option value carrying an expansion or glob (unknown excluded
  set), and any extra executed token carrying glob metacharacters
  (crafted option-looking filenames narrow invisibly) are unprovable.
  Criterion-side globs stay self-consistent (literal match).
- cd: an argument carrying a runtime expansion or glob is non-silent
  (unknown destination, unknown print); CDPATH= assignments are now
  handled as state pollution at the match layer, subsuming the
  value-shape special case.

* fix(harness): persistent-shell evidence, exact env sets, option-arity scoping (RFC #4651 PR4)

- tests_passed: on a persistent-shell provider (new
  Sandbox.persistent_shell_sessions capability, set by AioSandbox) every
  leaf degrades to UNVERIFIED — any earlier call in the shared session
  could have mutated the state the clean-looking run executed in, and
  only a fresh controlled session (RFC section 6 verifier) can prove
  otherwise. The flag is read from the provider registry without
  acquiring a sandbox.
- env assignments: the allowlist is gone — no variable is provably inert
  across repositories (CI/DEBUG are routinely read by tests). The span's
  assignment prefix must equal the criterion's exactly (values included,
  order-insensitive); any assignment or export NAME= in a preceding
  segment is state pollution.
- scoping: positional targets are now read by option arity, so a path
  embedded in an option (--basetemp=/tmp/p, --junitxml=/tmp/r.xml) never
  counts as a selection target and an extra positional after such a
  criterion narrows the default selection it denotes.

* fix(harness): stamp shell provenance at harvest, close export/unset and arity gaps (RFC #4651 PR4)

* fix(harness): split physical newlines as shell separators in acceptance matching (RFC #4651 PR4)

* fix(harness): scope cd wrappers to thread data roots, pin accepted boundaries (RFC #4651 PR4)

* fix(harness): preserve criterion connectors, prove file_written readable, fail-closed shell capability (RFC #4651 PR4)

* fix(harness): compare only the connector prefix, tolerate trailing criterion semicolons (RFC #4651 PR4)

* fix(harness): preserve continuation-line operators, keep ./-spelled executable identity (RFC #4651 PR4)

* fix(harness): render criteria single-line so a multiline criterion cannot inject a forged checklist line (RFC #4651 PR4)

* fix(harness): reject parent-traversal executable tokens in acceptance matching (RFC #4651 PR4)

* fix(harness): reject parent-traversal negated values in acceptance matching (RFC #4651 PR4)
2026-09-01 16:13:41 +08:00

462 lines
17 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 GoalState
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 ViewedImageData(TypedDict):
"""Metadata for a viewed image file.
Only lightweight metadata is persisted in checkpoint state; the actual
image bytes are read on-demand from disk when the model needs them.
This avoids duplicating large base64 payloads across every checkpoint
(see #4138).
"""
mime_type: str
size: int
actual_path: str
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.
"""
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
return {**existing, **new}
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]
def merge_promoted(existing: PromotedTools | None, new: PromotedTools | None) -> PromotedTools | None:
"""Reducer for deferred-tool promotions, scoped by catalog hash.
- new None/empty -> preserve existing (node didn't touch promotions).
- catalog_hash changed -> replace wholesale, dropping stale names (prevents a
persisted bare name from exposing a different tool after catalog drift).
- same catalog_hash -> union names, dedupe, preserve order.
"""
if not new:
return existing
if existing is None or existing.get("catalog_hash") != new["catalog_hash"]:
return {
"catalog_hash": new["catalog_hash"],
"names": list(dict.fromkeys(new["names"])),
}
return {
"catalog_hash": existing["catalog_hash"],
"names": list(dict.fromkeys(existing["names"] + new["names"])),
}
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
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 same id with the latest version while preserving
first-seen order.
- terminal status is never overwritten by a non-terminal status.
"""
if not new:
return existing or []
by_id: dict[str, DelegationEntry] = {}
order: list[str] = []
for entry in [*(existing or []), *new]:
entry_id = entry["id"]
previous = by_id.get(entry_id)
if previous is not None and previous["status"] in TERMINAL_STATUSES and entry["status"] not in TERMINAL_STATUSES:
continue
if entry_id not in by_id:
order.append(entry_id)
elif previous.get("created_at"):
entry = {**entry, "created_at": previous["created_at"]}
if previous.get("run_id") and not entry.get("run_id"):
entry["run_id"] = previous["run_id"]
by_id[entry_id] = entry
merged = [by_id[entry_id] for entry_id 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
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]
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]
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",
}
)
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