Compare commits

...
Author SHA1 Message Date
Nick HollonandGitHub 4dde40000f Merge branch 'main' into nh/namespace-keyed-subagents 2026-05-08 14:01:05 -04:00
Quanzheng LongandGitHub fb6e5c2bce chore: remove keepset helper (#7745)
Comment follow up
2026-05-08 18:00:31 +00:00
Nick Hollon 8de6eb4eab rename LifecyclePayload.cause to metadata
`metadata` reads more neutrally than `cause` — the field carries
descriptive info about what the lifecycle event is for, not a
strict causal relationship. Wire field, docstrings, and tests updated.
2026-05-08 13:57:58 -04:00
6dade64aa7 chore: remove unnecessary missing-typed-dict-key suppression (#7744)
Address a comment as follow up

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 10:39:54 -07:00
Nick Hollon c6dc0c32c1 broaden graph_name docstring beyond add_node-only case
The namespace segment name is whatever pregel registered the
dispatching node as in the parent scope (`_algo.py:615`/`:988`),
not specifically an `add_node` mount. For a subagent invoked from
inside a tool's body, that's the tool's name; for Send fan-out,
the target node's name.
2026-05-08 11:43:04 -04:00
Nick Hollon 58e0773ec6 rename trigger_call_id to parent_task_id
Pregel doesn't have a "call" concept — its unit of work is a task. The
namespace segment after `:` is documented in `_internal/_constants.py`
as the `task_id` ("for checkpoint_ns, for each level, separates the
namespace from the task_id"), and "parent task" already shows up as
existing pregel vocabulary (`_algo.py`, `_runner.py`).

`parent_task_id` matches that vocabulary: from the child subgraph's
perspective, the id we carry on every lifecycle event identifies the
task in the parent scope whose execution dispatched this subgraph.
Renames the wire field, the `SubgraphRunStream.parent_task_id`
attribute, all internal parameter / variable names, and the test
identifiers.
2026-05-08 11:38:27 -04:00
Nick Hollon 8c4bfa6d07 slim lifecycle.cause to {type, tool_call_id}, require trigger_call_id
The args-mining of `subagent_type` and `description` coupled the
streaming layer to one specific tool's args schema (the deepagents
`task` tool). Drop it: cause now carries only `{type: "tool_call",
tool_call_id}`. Args still live on the AIMessage's `tool_calls[i].args`
where they originate — consumers that want descriptive intent look
them up there via the `tool_call_id` join, keeping one source of truth.

Also promote `trigger_call_id` from `NotRequired` to required and
emit it on every event (started + every terminal path). Consumers
no longer need a namespace fallback to correlate `started` ↔ terminal
events for the same subgraph instance.

Renames:
- `_extract_per_call_args` -> `_extract_dispatching_tool_call_id`
  (now returns just `str | None`)
- `_record_invocation_metadata` -> `_record_dispatching_tool_call_id`
- `_invocation_metadata` cache -> `_dispatching_tool_call_id`
- `_on_started` parameter `invocation_metadata` -> `tool_call_id`
- `_on_terminal` gains a `trigger_call_id` parameter; `_pop_terminal_transitions`
  yields it through the tuple

Tests refreshed: cause-shape assertions reflect the slimmed payload,
new `test_trigger_call_id_present_on_every_terminal_path` covers all
five terminal exit paths, list-shape robustness test re-aimed at
missing `id` instead of missing args.
2026-05-08 10:57:29 -04:00
Nick Hollon ee9e234da0 rename SubgraphStatus to LifecycleEvent, add per-field/value docs
`SubgraphStatus` was misleading — values like `started` are transitions,
not statuses, and the field on `LifecyclePayload` is already called
`event`. Rename the type alias to `LifecycleEvent` so the type and the
field agree. Add a block docstring on the alias enumerating each value's
trigger condition, and add per-field docstrings on `LifecyclePayload`
(`event` / `namespace` / `graph_name` / `trigger_call_id` / `error`),
matching the style already used for `cause`. No behaviour change.
2026-05-08 10:42:39 -04:00
Nick Hollon fc297fc576 stop naming ToolCallWithContext in lifecycle docstrings
`ToolCallWithContext` is an internal prebuilt type — public-facing
docstrings shouldn't be calling it out by name. Describe the two
recognised per-call envelope shapes (single-element list of tool-call
dicts, dict wrapping a `tool_call`) by structure instead, and lead
with the list shape since that's what `langchain.agents.create_agent`
currently dispatches.
2026-05-08 10:04:49 -04:00
Nick Hollon 837e1ba6d2 extract per-call args helper, evict invocation cache after consumption
- Pull the two-shape duck-typing out of `_record_invocation_metadata`
  into a module-level `_extract_per_call_args` helper so the recording
  method is a clean three-step flow (validate task_id, parse shape,
  mine fields).
- Pop `_invocation_metadata` entries when consumed in `_handle_task_start`
  to prevent unbounded per-stream-call growth on long subagent runs.
- Fix stale `_on_started` parameter name in `_TasksLifecycleBase` class
  docstring (was `tool_call_id`, now `invocation_metadata`).
- Add regression test `test_parallel_dispatches_attributed_to_correct_parent`
  exercising the actual bug the trigger_call_id join defends against:
  two parents with identical subagent_type but distinct descriptions
  must each get their own cause attributed via trigger_call_id.
2026-05-08 09:37:23 -04:00
Nick Hollon 22d4ccaa3b feat(langgraph): expose tool_call_id on lifecycle.started.cause
Capture the model-side tool_call_id alongside subagent_type/description
when mining per-call envelopes (both ToolCallWithContext dict and the
single-element list shape), and surface it under cause['tool_call_id'].
Identity-level correlation across lifecycle events still uses
trigger_call_id (the pregel task id, unique per-call); tool_call_id is
purely anchoring metadata so UI consumers can map a lifecycle event
back to the AI message tool call that dispatched it. The per-call Send
fan-out makes tool_call_id ↔ trigger_call_id 1:1, so re-introducing it
here doesn't reintroduce the parallel-call conflation that motivated
its earlier removal.
2026-05-07 23:47:52 -04:00
398d6cc59d chore(langgraph): add guide/conformance for delta channel checkpointer (#7736)
## Summary

Add a user-facing design doc and `get_delta_channel_keepset` helper for
third-party `BaseCheckpointSaver` authors who need to support graphs
using `DeltaChannel`.

**Deliverables:**

1. ~~**`docs/delta-channel-checkpointer-guide.md`** — comprehensive
guide covering~~:
moved to docs repo

2. **`BaseCheckpointSaver.get_delta_channel_keepset` /
`aget_delta_channel_keepset`** — returns the minimum set of ancestor
`checkpoint_id`s that must survive deletion for a given head's
`DeltaChannel` reconstruction to remain intact. Enables safe `prune`
implementations without silently corrupting delta history.

3. **Docstring warnings** on `prune`, `aprune`, `delete_for_runs`,
`adelete_for_runs`, `copy_thread`, `acopy_thread` explaining the
DeltaChannel pitfall (silent data loss if ancestor writes/snapshots are
deleted).

4. **Three new conformance capabilities** in
`libs/checkpoint-conformance`:
- `delta_channel_history` — validates the `aget_delta_channel_history`
walk contract
   - `delta_channel_keepset` — validates the keep-set contract
- `delta_channel_reconstruction` — end-to-end round-trip (aput +
aput_writes + history + reconstruct)

## Test plan

- [x] `make format lint` passes in `libs/checkpoint`,
`libs/checkpoint-conformance`
- [x] All three new conformance capabilities pass against
`InMemorySaver`
- [x] Run conformance against SQLite saver 
- [x] Run conformance against Postgres saver

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 17:34:46 -07:00
Nick Hollon 4504f85157 refactor(transformers): rename spawn_metadata to invocation_metadata 2026-05-07 16:54:19 -04:00
Nick Hollon 30b0ebe5bf fix(transformers): also mine spawn metadata from list-shape per-call input
langchain v1 create_agent dispatches tool calls via Send("tools",
[tool_call]) — a single-element list of tool-call dicts, not the
ToolCallWithContext dict envelope used by langgraph prebuilt's
create_agent. Both shapes need to feed _record_spawn_metadata so
lifecycle.started.cause carries subagent_type / description
regardless of which agent factory drove the dispatch.
2026-05-07 16:54:19 -04:00
Nick Hollon d3a5a6e283 refactor(transformers): drop tool_call_id from LifecyclePayload.cause
Cause now carries spawn intent metadata (subagent_type, description)
mined from the per-call task's ToolCallWithContext input, keyed off
the existing trigger_call_id (pregel task id) which is unique per
spawn. Tool_call_id is no longer needed as a join key — consumers
that want the model-layer id can look it up themselves; this layer
is no longer the indirection point.
2026-05-07 16:54:19 -04:00
d736564eb1 test(langgraph): de-flake heartbeat progress test (#7735)
## Summary

De-flake
`test_arun_with_retry_timeout_observer_emits_progress_on_heartbeat` —
the test was hitting a CI-runner-load-sensitive race where the
idle-timeout watchdog could fire before the task body's first await ran.

## Root cause

`_TimedAttemptScope.__init__` sets `_last_progress = time.monotonic()`
immediately, but the watchdog itself doesn't start polling until *after*
`wrap_config` and task scheduling. Under heavy CI load that gap can grow
large enough that:

```
T₀  scope.__init__()  →  _last_progress = T₀
… some scheduling slack …
Tₙ  watchdog runs, computes  remaining = T₀ + 0.2 − Tₙ ≤ 0  →  TimeoutError fires
```

The error reports `elapsed: 0.000s` because `elapsed` is measured from
the post-scheduling `start` (≈Tₙ), not from `_last_progress` (T₀). The
previous test set `idle_timeout=0.2s`, which left almost no headroom for
that scheduling slack.

## Fix (test-side only — no production change)

- **Heartbeat at task-body entry**: `runtime.heartbeat()` is now called
before the first `await asyncio.sleep(...)`, which resets
`_last_progress` to "now" the moment the task body actually starts
running. This eliminates the scope-init-to-first-await gap as a flake
source.
- **Idle timeout 0.2s → 1.0s**: gives ~5× headroom over the ~400ms task
duration, so scheduling pressure stays comfortably within budget.

## Why test-side instead of fixing the production race

The proper production fix would be to set `_last_progress` at
watchdog-entry time rather than at scope-init time. That's a behaviour
change in the retry/timeout machinery and out of scope for a flaky-test
fix. The two test-side defenses make this particular test stable without
touching production semantics; the underlying race in
`_TimedAttemptScope` is worth a separate follow-up.

## Test plan

- 10/10 repeated local runs pass:
  ```
uv run pytest
tests/test_retry.py::test_arun_with_retry_timeout_observer_emits_progress_on_heartbeat
--count=10
  ```
- All assertions still meaningful: still verifies start/finish events,
at least one progress event, rate-limited progress count (≤ total
events), and per-event metadata (task_name, attempt, idle_timeout_secs,
progress_at).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 10:31:18 -07:00
17 changed files with 1641 additions and 581 deletions
+1
View File
@@ -63,6 +63,7 @@ The suite tests **base** capabilities (required) and **extended** capabilities (
| `delete_for_runs` | no | `adelete_for_runs` |
| `copy_thread` | no | `acopy_thread` |
| `prune` | no | `aprune` |
| `delta_channel_history` | no | `aget_delta_channel_history` |
Extended capabilities are detected by checking whether the method is overridden from `BaseCheckpointSaver`. If not overridden, those tests are skipped.
@@ -23,6 +23,7 @@ class Capability(str, Enum):
DELETE_FOR_RUNS = "delete_for_runs"
COPY_THREAD = "copy_thread"
PRUNE = "prune"
DELTA_CHANNEL_HISTORY = "delta_channel_history"
# Capabilities that every checkpointer must support.
@@ -42,6 +43,7 @@ EXTENDED_CAPABILITIES = frozenset(
Capability.DELETE_FOR_RUNS,
Capability.COPY_THREAD,
Capability.PRUNE,
Capability.DELTA_CHANNEL_HISTORY,
}
)
@@ -57,6 +59,7 @@ _CAPABILITY_METHOD_MAP: dict[Capability, str] = {
Capability.DELETE_FOR_RUNS: "adelete_for_runs",
Capability.COPY_THREAD: "acopy_thread",
Capability.PRUNE: "aprune",
Capability.DELTA_CHANNEL_HISTORY: "aget_delta_channel_history",
}
@@ -9,6 +9,9 @@ from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
run_delete_thread_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
run_delta_channel_history_tests,
)
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
@@ -24,4 +27,5 @@ __all__ = [
"run_delete_for_runs_tests",
"run_copy_thread_tests",
"run_prune_tests",
"run_delta_channel_history_tests",
]
@@ -0,0 +1,99 @@
"""Shared fixtures for delta-channel conformance tests.
Builds a parent chain with `_DeltaSnapshot` blobs at known positions via
direct `aput` / `aput_writes` calls. No langgraph or Pregel dependency.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from uuid import uuid4
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.conformance.test_utils import generate_metadata
async def build_delta_chain(
saver: BaseCheckpointSaver,
*,
thread_id: str | None = None,
checkpoint_ns: str = "",
channel: str = "messages",
snapshots_at_steps: Sequence[int] = (0,),
total_steps: int = 6,
write_value_fn: Any | None = None,
) -> list[RunnableConfig]:
"""Build a parent chain with `_DeltaSnapshot` at known positions.
Args:
saver: Checkpointer instance.
thread_id: Defaults to a random UUID.
checkpoint_ns: Namespace (default root).
channel: Channel name used for snapshots and writes.
snapshots_at_steps: Steps at which a `_DeltaSnapshot` blob is stored
in `channel_values[channel]`. Step 0 is the oldest checkpoint.
total_steps: Number of checkpoints in the chain.
write_value_fn: Callable(step) -> write value. Defaults to step index.
Returns:
List of stored configs (oldest first), one per step.
"""
if write_value_fn is None:
def write_value_fn(step: int) -> Any:
return step
from langgraph.checkpoint.serde.types import _DeltaSnapshot
thread_id = thread_id or str(uuid4())
snapshot_set = set(snapshots_at_steps)
stored: list[RunnableConfig] = []
parent_cfg: RunnableConfig | None = None
for step in range(total_steps):
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
}
}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
channel_values: dict[str, Any] = {}
channel_versions: dict[str, int] = {}
if step in snapshot_set:
channel_values[channel] = _DeltaSnapshot(
write_value_fn(step),
)
channel_versions[channel] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=channel_values,
channel_versions=channel_versions,
versions_seen={},
updated_channels=None,
)
new_versions = dict(channel_versions)
parent_cfg = await saver.aput(
config, cp, generate_metadata(step=step), new_versions
)
stored.append(parent_cfg)
# Write a pending write for non-snapshot steps so the walk has
# something to collect.
if step not in snapshot_set:
await saver.aput_writes(
parent_cfg, [(channel, write_value_fn(step))], str(uuid4())
)
return stored
@@ -0,0 +1,247 @@
"""DELTA_CHANNEL_HISTORY capability tests — aget_delta_channel_history contract."""
from __future__ import annotations
import traceback
from collections.abc import Callable
from uuid import uuid4
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
async def test_history_returns_writes_oldest_first(
saver: BaseCheckpointSaver,
) -> None:
"""Writes are returned oldest-to-newest."""
tid = str(uuid4())
# 5 steps: snapshot at 0, writes at 1,2,3,4.
# Head is step 4. Walk starts at step 3 (parent of head).
# Collects writes from steps 1,2,3 (between snapshot at 0 and head's parent).
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=5
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert values == [1, 2, 3], f"Expected [1,2,3], got {values}"
async def test_history_seed_is_nearest_snapshot(
saver: BaseCheckpointSaver,
) -> None:
"""Seed is the value from the nearest ancestor with channel_values populated."""
tid = str(uuid4())
# 6 steps: snapshots at 0 and 3, writes at 1,2,4,5.
# Head is step 5. Walk from step 4 backward stops at step 3 (snapshot).
# Collects writes from step 4 only (between step 3 and head's parent step 4).
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="ch",
snapshots_at_steps=[0, 3],
total_steps=6,
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
assert "seed" in result["ch"], "Expected seed from snapshot at step 3"
seed = result["ch"]["seed"]
from langgraph.checkpoint.serde.types import _DeltaSnapshot
actual_value = seed.value if isinstance(seed, _DeltaSnapshot) else seed
assert actual_value == 3, f"Expected seed value 3 (step 3), got {actual_value}"
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert values == [4], f"Expected [4], got {values}"
async def test_history_excludes_target_pending_writes(
saver: BaseCheckpointSaver,
) -> None:
"""Target's own pending_writes are NOT included in the history."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3
)
head = configs[-1]
# Add writes directly to the head checkpoint
await saver.aput_writes(head, [("ch", "extra")], str(uuid4()))
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert "extra" not in values, f"Target's writes should be excluded, got {values}"
async def test_history_multi_channel(
saver: BaseCheckpointSaver,
) -> None:
"""Multiple channels have independent walk termination."""
tid = str(uuid4())
configs: list = []
parent_cfg = None
from langgraph.checkpoint.base import Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.checkpoint.conformance.test_utils import generate_metadata
for step in range(5):
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
cv: dict = {}
cvs: dict = {}
if step == 1:
cv["a"] = _DeltaSnapshot("snap_a")
cvs["a"] = step + 1
if step == 3:
cv["b"] = _DeltaSnapshot("snap_b")
cvs["b"] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=cv,
channel_versions=cvs,
versions_seen={},
updated_channels=None,
)
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
configs.append(parent_cfg)
await saver.aput_writes(parent_cfg, [("a", step), ("b", step)], str(uuid4()))
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["a", "b"])
a_writes = [w[2] for w in result["a"]["writes"]]
b_writes = [w[2] for w in result["b"]["writes"]]
assert a_writes == [1, 2, 3], f"Expected a writes [1,2,3], got {a_writes}"
assert b_writes == [3], f"Expected b writes [3], got {b_writes}"
async def test_history_empty_channels_returns_empty(
saver: BaseCheckpointSaver,
) -> None:
"""Empty channels list returns empty mapping."""
tid = str(uuid4())
configs = await build_delta_chain(
saver, thread_id=tid, channel="ch", snapshots_at_steps=[0], total_steps=3
)
result = await saver.aget_delta_channel_history(config=configs[-1], channels=[])
assert result == {}
async def test_history_walk_to_root_no_seed(
saver: BaseCheckpointSaver,
) -> None:
"""Walk reaches root without finding seed — no 'seed' key in result."""
tid = str(uuid4())
configs = await build_delta_chain(
saver,
thread_id=tid,
channel="ch",
snapshots_at_steps=[],
total_steps=4,
)
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
assert "seed" not in result["ch"], f"Expected no seed, got {result['ch']}"
async def test_history_migration_plain_value_as_seed(
saver: BaseCheckpointSaver,
) -> None:
"""Pre-delta plain value in channel_values acts as seed (migration case).
When a thread was originally using a regular channel (BinaryOperatorAggregate)
and later switches to DeltaChannel, the old checkpoint has a plain value in
channel_values[ch] (not a _DeltaSnapshot). The walk should treat it as the
seed and terminate there.
"""
from langgraph.checkpoint.base import Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.conformance.test_utils import generate_metadata
tid = str(uuid4())
configs: list = []
parent_cfg = None
for step in range(4):
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
if parent_cfg:
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
"checkpoint_id"
]
cv: dict = {}
cvs: dict = {}
# Step 1: plain value (migration case — old checkpoint before delta)
if step == 1:
cv["ch"] = [10, 20, 30]
cvs["ch"] = step + 1
cp = Checkpoint(
v=1,
id=str(uuid6(clock_seq=-1)),
ts="",
channel_values=cv,
channel_versions=cvs,
versions_seen={},
updated_channels=None,
)
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
configs.append(parent_cfg)
if step != 1:
await saver.aput_writes(parent_cfg, [("ch", step)], str(uuid4()))
head = configs[-1]
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
# Seed should be the plain value from step 1
assert "seed" in result["ch"], "Expected seed from migration plain value at step 1"
seed = result["ch"]["seed"]
assert seed == [10, 20, 30], f"Expected plain value [10,20,30], got {seed}"
# Writes should be from step 2 only (between seed at step 1 and head's parent step 2)
writes = result["ch"]["writes"]
values = [w[2] for w in writes]
assert values == [2], f"Expected [2], got {values}"
ALL_DELTA_CHANNEL_HISTORY_TESTS = [
test_history_returns_writes_oldest_first,
test_history_seed_is_nearest_snapshot,
test_history_excludes_target_pending_writes,
test_history_multi_channel,
test_history_empty_channels_returns_empty,
test_history_walk_to_root_no_seed,
test_history_migration_plain_value_as_seed,
]
async def run_delta_channel_history_tests(
saver: BaseCheckpointSaver,
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
) -> tuple[int, int, list[str]]:
"""Run all delta_channel_history tests. Returns (passed, failed, failure_names)."""
passed = 0
failed = 0
failures: list[str] = []
for test_fn in ALL_DELTA_CHANNEL_HISTORY_TESTS:
try:
await test_fn(saver)
passed += 1
if on_test_result:
on_test_result("delta_channel_history", test_fn.__name__, True, None)
except Exception:
failed += 1
msg = f"{test_fn.__name__}: {traceback.format_exc()}"
failures.append(msg)
if on_test_result:
on_test_result(
"delta_channel_history",
test_fn.__name__,
False,
traceback.format_exc(),
)
return passed, failed, failures
@@ -19,6 +19,9 @@ from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
run_delete_thread_tests,
)
from langgraph.checkpoint.conformance.spec.test_delta_channel_history import (
run_delta_channel_history_tests,
)
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
@@ -35,6 +38,7 @@ _RUNNERS = {
Capability.DELETE_FOR_RUNS: run_delete_for_runs_tests,
Capability.COPY_THREAD: run_copy_thread_tests,
Capability.PRUNE: run_prune_tests,
Capability.DELTA_CHANNEL_HISTORY: run_delta_channel_history_tests,
}
@@ -43,7 +43,11 @@ asyncio_mode = "auto"
# The extended methods (acopy_thread, adelete_for_runs, aprune) are checked
# at runtime via capability detection and may not exist on the installed
# base class. Dict literal inference is also overly strict for RunnableConfig.
# Delta-channel tests import from `langgraph` (not a declared dep of this
# package — at test time it is installed alongside); private `_DeltaSnapshot`
# imports are intentional (beta surface).
unresolved-attribute = "ignore"
unresolved-import = "ignore"
invalid-argument-type = "ignore"
invalid-return-type = "ignore"
@@ -58,6 +62,9 @@ lint.select = [
lint.ignore = ["E501", "B008"]
target-version = "py310"
[tool.uv.sources]
langgraph-checkpoint = {path = "../checkpoint", editable = true}
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
+605 -504
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
"""Run delta-channel conformance capabilities against AsyncSqliteSaver."""
from __future__ import annotations
import pytest
pytest.importorskip(
"langgraph.checkpoint.conformance",
reason="langgraph-checkpoint-conformance not installed",
)
pytest.importorskip("aiosqlite", reason="aiosqlite not installed")
@pytest.mark.asyncio
async def test_delta_channel_conformance():
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
@checkpointer_test(name="AsyncSqliteSaver")
async def sqlite_saver():
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
yield saver
report = await validate(
sqlite_saver,
capabilities={
"delta_channel_history",
},
)
for cap, result in report.results.items():
if result.passed is False:
details = "\n".join(result.failures or [])
pytest.fail(f"Capability {cap} failed:\n{details}")
@@ -327,6 +327,14 @@ class BaseCheckpointSaver(Generic[V]):
Args:
run_ids: The run IDs whose checkpoints should be deleted.
!!! warning "DeltaChannel"
Deleting a run that produced ancestor `checkpoint_writes` — or
the only `_DeltaSnapshot` blob — for a still-live thread will
break reconstruction of any `DeltaChannel` whose history
depended on those rows. See the `DeltaChannel` note on `prune`
for safe-recovery strategies.
"""
raise NotImplementedError
@@ -340,6 +348,17 @@ class BaseCheckpointSaver(Generic[V]):
Args:
source_thread_id: The thread ID to copy from.
target_thread_id: The thread ID to copy to.
!!! warning "DeltaChannel"
Implementations must copy the **complete** parent chain (all
ancestor checkpoints and their `checkpoint_writes`) — copying
only the head checkpoint will leave the target thread with
`DeltaChannel` state that cannot be reconstructed (no path back
to a `_DeltaSnapshot` ancestor). Equivalently, the copy must
include enough ancestors that every `DeltaChannel`-backed key
has either a `_DeltaSnapshot` in `channel_values` somewhere in
the chain, or a complete write history back to the chain root.
"""
raise NotImplementedError
@@ -355,6 +374,34 @@ class BaseCheckpointSaver(Generic[V]):
thread_ids: The thread IDs to prune.
strategy: The pruning strategy. `"keep_latest"` retains only the most
recent checkpoint per namespace. `"delete"` removes all checkpoints.
!!! warning "DeltaChannel"
Custom implementations must be `DeltaChannel`-aware. `DeltaChannel`
stores only a sentinel in `channel_values` for non-snapshot steps;
reconstruction walks the parent chain via
`get_delta_channel_history`, accumulating rows from
`checkpoint_writes` until it reaches an ancestor whose
`channel_values` contains a `_DeltaSnapshot` blob (written every
`snapshot_frequency` updates).
A naive `"keep_latest"` that drops intermediate checkpoints and
their writes can sever that chain: the surviving "latest"
checkpoint is rarely a snapshot point itself, so its delta
channels would silently reconstruct as empty (no error raised —
`get_delta_channel_history` simply returns no `seed`). Safe
options when the graph uses `DeltaChannel`:
* Walk back from each kept checkpoint and preserve every
ancestor (plus its `checkpoint_writes`) up to the nearest one
whose `channel_values` already contains a `_DeltaSnapshot` for
every `DeltaChannel`-backed key.
* Force a fresh snapshot on the kept checkpoint before deleting
ancestors — rewrite `channel_values[k] = _DeltaSnapshot(value)`
for each delta channel `k` (resolving `value` via the existing
ancestor walk first), then prune.
* Skip pruning threads whose graph uses `DeltaChannel` until one
of the above is implemented.
"""
raise NotImplementedError
@@ -471,6 +518,13 @@ class BaseCheckpointSaver(Generic[V]):
Args:
run_ids: The run IDs whose checkpoints should be deleted.
!!! warning "DeltaChannel"
See `delete_for_runs` — deleting rows a still-live thread's
`DeltaChannel` reconstruction depends on (writes between the
head and its nearest `_DeltaSnapshot` ancestor) will silently
corrupt that channel's state.
"""
raise NotImplementedError
@@ -484,6 +538,13 @@ class BaseCheckpointSaver(Generic[V]):
Args:
source_thread_id: The thread ID to copy from.
target_thread_id: The thread ID to copy to.
!!! warning "DeltaChannel"
See `copy_thread` — the copy must carry the complete parent
chain (or at least back to a `_DeltaSnapshot` ancestor for every
`DeltaChannel`) so the target thread can reconstruct delta
state.
"""
raise NotImplementedError
@@ -499,6 +560,13 @@ class BaseCheckpointSaver(Generic[V]):
thread_ids: The thread IDs to prune.
strategy: The pruning strategy. `"keep_latest"` retains only the most
recent checkpoint per namespace. `"delete"` removes all checkpoints.
!!! warning "DeltaChannel"
See `prune` for the full `DeltaChannel` caveat. In short:
`"keep_latest"` must not drop ancestor checkpoints / writes that
sit between the kept checkpoint and the nearest `_DeltaSnapshot`
ancestor, or delta channels will silently reconstruct as empty.
"""
raise NotImplementedError
@@ -0,0 +1,33 @@
"""Run delta-channel conformance capabilities against InMemorySaver."""
from __future__ import annotations
import pytest
conformance = pytest.importorskip(
"langgraph.checkpoint.conformance",
reason="langgraph-checkpoint-conformance not installed",
)
@pytest.mark.asyncio
async def test_delta_channel_conformance():
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
from langgraph.checkpoint.memory import InMemorySaver
@checkpointer_test(name="InMemorySaver")
async def mem_saver():
yield InMemorySaver()
report = await validate(
mem_saver,
capabilities={
"delta_channel_history",
},
)
for cap, result in report.results.items():
if result.passed is False:
details = "\n".join(result.failures or [])
pytest.fail(f"Capability {cap} failed:\n{details}")
+2 -2
View File
@@ -17,9 +17,9 @@ from langgraph.stream.transformers import (
CheckpointsTransformer,
CustomTransformer,
DebugTransformer,
LifecycleEvent,
LifecyclePayload,
LifecycleTransformer,
SubgraphStatus,
SubgraphTransformer,
TasksTransformer,
UpdatesTransformer,
@@ -32,13 +32,13 @@ __all__ = [
"CustomTransformer",
"DebugTransformer",
"GraphRunStream",
"LifecycleEvent",
"LifecyclePayload",
"LifecycleTransformer",
"ProtocolEvent",
"StreamChannel",
"StreamTransformer",
"SubgraphRunStream",
"SubgraphStatus",
"SubgraphTransformer",
"TasksTransformer",
"UpdatesTransformer",
@@ -12,7 +12,7 @@ from langgraph.stream._mux import StreamMux
from langgraph.stream._types import ProtocolEvent
if TYPE_CHECKING:
from langgraph.stream.transformers import SubgraphStatus
from langgraph.stream.transformers import LifecycleEvent
def _drive_until_done(pump: Callable[[], bool]) -> None:
@@ -523,8 +523,8 @@ class _SubgraphRunStreamMixin:
path: tuple[str, ...]
graph_name: str | None
trigger_call_id: str | None
status: SubgraphStatus
parent_task_id: str | None
status: LifecycleEvent
error: str | None
_seen_terminal: bool
@@ -538,7 +538,7 @@ class SubgraphRunStream(GraphRunStream, _SubgraphRunStreamMixin):
*,
path: tuple[str, ...],
graph_name: str | None = None,
trigger_call_id: str | None = None,
parent_task_id: str | None = None,
) -> None:
# Capture the parent-inherited pump before super().__init__
# touches anything; we delegate to it from `_pump_next`.
@@ -550,7 +550,7 @@ class SubgraphRunStream(GraphRunStream, _SubgraphRunStreamMixin):
)
self.path = path
self.graph_name = graph_name
self.trigger_call_id = trigger_call_id
self.parent_task_id = parent_task_id
self.status = "started"
self.error = None
self._seen_terminal = False
@@ -581,7 +581,7 @@ class AsyncSubgraphRunStream(AsyncGraphRunStream, _SubgraphRunStreamMixin):
*,
path: tuple[str, ...],
graph_name: str | None = None,
trigger_call_id: str | None = None,
parent_task_id: str | None = None,
) -> None:
self._parent_apump_fn: Callable[[], Awaitable[bool]] | None = mux._apump_fn
super().__init__(
@@ -591,7 +591,7 @@ class AsyncSubgraphRunStream(AsyncGraphRunStream, _SubgraphRunStreamMixin):
)
self.path = path
self.graph_name = graph_name
self.trigger_call_id = trigger_call_id
self.parent_task_id = parent_task_id
self.status = "started"
self.error = None
self._seen_terminal = False
+230 -58
View File
@@ -327,11 +327,31 @@ class MessagesTransformer(StreamTransformer):
self._by_run.clear()
SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"]
LifecycleEvent = Literal["started", "completed", "failed", "interrupted", "drained"]
"""State transition surfaced on the `lifecycle` channel for a tracked subgraph.
Each value:
- `started` — first `tasks` event observed at the tracked namespace. Carries
`graph_name` / `parent_task_id` / optional `metadata` describing what spawned
the subgraph.
- `completed` — the dispatching task's `TaskResultPayload` arrived with neither
error nor interrupts. Also emitted by `finalize` for any tracked namespace
still open at run end.
- `failed` — the dispatching task's result carried an `error`, OR the run
failed at top level with a non-interrupt / non-drain exception. Carries
`error` (string).
- `interrupted` — the dispatching task's result carried `interrupts` (takes
precedence over `error` if both present), OR the run failed with
`GraphInterrupt`.
- `drained` — the run was cooperatively stopped at a superstep boundary via
`RunControl.request_drain()` (e.g. SIGTERM). The checkpoint is saved and
the run is resumable.
"""
def _parse_ns_segment(segment: str) -> tuple[str, str | None]:
"""Split a namespace segment into `(graph_name, trigger_call_id)`.
"""Split a namespace segment into `(graph_name, parent_task_id)`.
Segments are formatted `node_name:task_id` by `prepare_next_tasks`.
Returns `(segment, None)` if no `:` is present.
@@ -340,6 +360,36 @@ def _parse_ns_segment(segment: str) -> tuple[str, str | None]:
return name, task_id if sep else None
def _extract_dispatching_tool_call_id(payload: Any) -> str | None:
"""Return the model-side `tool_call_id` from a per-call dispatched task's
`input`, or `None` if the payload doesn't match a recognised shape.
Two shapes are recognised; both are duck-typed so any tool runner
that mimics the layout participates without naming any specific
dispatcher's types:
1. Single-element list of tool-call dicts:
`[{"id": ..., "name": ..., "args": {...}}]`. The current public
shape — `langchain.agents.create_agent` Send-fans this out per
pending tool call.
2. Dict envelope wrapping a tool call:
`{"tool_call": {"id": ..., "args": {...}, ...}, ...}`. Older
prebuilt agent paths Send-fan-out this shape.
"""
if isinstance(payload, dict):
tool_call = payload.get("tool_call")
if not isinstance(tool_call, dict):
return None
elif (
isinstance(payload, list) and len(payload) == 1 and isinstance(payload[0], dict)
):
tool_call = payload[0]
else:
return None
raw_id = tool_call.get("id")
return raw_id if isinstance(raw_id, str) else None
class LifecyclePayload(TypedDict, total=False):
"""Payload of a lifecycle event surfaced on the `lifecycle` channel.
@@ -349,11 +399,54 @@ class LifecyclePayload(TypedDict, total=False):
`run.lifecycle`.
"""
event: SubgraphStatus
event: LifecycleEvent
"""State transition. See `LifecycleEvent` for per-value semantics."""
namespace: list[str]
"""Checkpoint namespace of the subgraph the event is about. Always present.
A list of `node_name:task_id` segments, one per nesting level (root has
`[]`, a direct child of root has `["agent:abc123"]`, a grandchild has
`["agent:abc123", "tool:def456"]`, etc.). Stable identity across the
`started → terminal` pair for the same subgraph instance.
"""
graph_name: NotRequired[str]
trigger_call_id: NotRequired[str]
"""Name of the parent-scope node that dispatched this subgraph
(`add_node` name, surrounding tool's name for in-tool invokes,
`Send` target name, etc.) — parsed from the namespace tail
segment. Absent when the segment has no `:` separator.
"""
parent_task_id: str
"""Pregel task id of the dispatching task — the task whose execution
spawned this subgraph.
Always present on every event for the same subgraph instance. This is
the join key for correlating `started` ↔ terminal events and for
matching a `started` back to its `tasks` parent. Each Send produces
its own pregel task with its own id, so the join is 1:1 even when a
model dispatches multiple parallel tool calls in one turn.
"""
metadata: NotRequired[dict[str, Any]]
"""Optional generic descriptor of *what triggered* this subgraph.
Forwarded by protocol layers as the wire `lifecycle.started.metadata` field.
Shape:
- `{"type": "tool_call", "tool_call_id": "<id>"}` — set when the subgraph
was triggered by a per-call tool dispatch (a model tool call routed
through whatever tool node the agent uses). `tool_call_id` is the
model-side id of the originating tool call, exposed so UI consumers
can anchor the lifecycle event back to the AI message that dispatched
it. The langgraph layer deliberately doesn't mine `args` — those live
on the AIMessage's `tool_calls[i].args` already, and consumers that
want descriptive intent (subagent type, prompt text, etc.) look it
up there to keep one source of truth.
Absent for structurally-triggered subgraphs (parallel branches via
`Send` with non-tool-call payloads, nested `graph.invoke()`, etc.) and
for tool dispatches whose envelope carried no `id`.
"""
error: NotRequired[str]
"""Error string. Set on `failed` events; absent otherwise."""
class _TasksLifecycleBase(StreamTransformer):
@@ -371,13 +464,13 @@ class _TasksLifecycleBase(StreamTransformer):
- `_should_track(ns)` — scope filter (e.g. multi-depth vs
direct-children-only).
- `_on_started(ns, graph_name, trigger_call_id)` — first sighting
action (push payload / build handle / etc.). Called once per
discovered namespace.
- `_on_terminal(ns, status, error)` — terminal action (push
terminal payload / mark handle status). Called once per
tracked namespace at result time, or via `finalize` / `fail`
sweeps if no parent result arrived.
- `_on_started(ns, graph_name, parent_task_id, tool_call_id)` —
first sighting action (push payload / build handle / etc.).
Called once per discovered namespace.
- `_on_terminal(ns, status, error, parent_task_id)` — terminal
action (push terminal payload / mark handle status). Called
once per tracked namespace at result time, or via `finalize` /
`fail` sweeps if no parent result arrived.
Tasks events are suppressed from the main event log (`process`
returns False) — they're folded into whichever projection the
@@ -390,9 +483,15 @@ class _TasksLifecycleBase(StreamTransformer):
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._seen: set[tuple[str, ...]] = set()
# Maps tracked namespace -> task_id of the parent task whose
# Maps tracked namespace -> task_id of the dispatching task whose
# `TaskResultPayload` will close it.
self._open: dict[tuple[str, ...], str] = {}
# Maps task_id -> model-side `tool_call_id` for tasks whose `input`
# matched a recognized per-call tool-dispatch shape. The lifecycle
# hook joins on this when a child subgraph fires its first task
# event so it can anchor the lifecycle.started to the originating
# AI message tool call.
self._dispatching_tool_call_id: dict[str, str] = {}
# --- Template-method hooks (subclass overrides) ---
@@ -404,19 +503,33 @@ class _TasksLifecycleBase(StreamTransformer):
self,
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
parent_task_id: str | None,
tool_call_id: str | None = None,
) -> None:
"""Fired once per discovered namespace (first observed task event)."""
"""Fired once per discovered namespace (first observed task event).
`tool_call_id` is the model-side id of the originating tool call
(from the per-call dispatched task's `input`). `None` for
structurally-triggered subgraphs or per-call envelopes that omitted
an `id`. Consumers join on `parent_task_id` (the pregel task id)
for identity; `tool_call_id` is purely an anchor back to the AI
message that dispatched the subgraph.
"""
raise NotImplementedError
def _on_terminal(
self,
ns: tuple[str, ...],
status: SubgraphStatus,
status: LifecycleEvent,
error: str | None,
parent_task_id: str,
) -> None:
"""Fired once per tracked namespace when its parent's result arrives,
or via finalize/fail safety-net sweeps.
"""Fired once per tracked namespace when its dispatching task's
result arrives, or via finalize/fail safety-net sweeps.
`parent_task_id` is the same id paired with the namespace at
`_on_started` time, so subscribers can correlate the terminal
event back to its `started`.
"""
raise NotImplementedError
@@ -430,56 +543,96 @@ class _TasksLifecycleBase(StreamTransformer):
if "result" in data:
self._handle_task_result(ns, data)
else:
self._handle_task_start(ns)
self._handle_task_start(ns, data)
# Tasks events are folded into the synthesized projections;
# suppress from the main event log so iterators don't double-see
# the same information in two shapes.
return False
def _handle_task_start(self, ns: tuple[str, ...]) -> None:
def _handle_task_start(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
# Mine input shape on every tasks event (not just tracked ones)
# so we capture dispatching tasks that themselves live outside the
# tracked region but whose `id` will appear as `parent_task_id`
# for a child subgraph.
self._record_dispatching_tool_call_id(data)
if not self._should_track(ns) or ns in self._seen:
return
self._seen.add(ns)
graph_name, trigger_call_id = _parse_ns_segment(ns[-1])
self._on_started(ns, graph_name or None, trigger_call_id)
if trigger_call_id is not None:
self._open[ns] = trigger_call_id
graph_name, parent_task_id = _parse_ns_segment(ns[-1])
tool_call_id = (
self._dispatching_tool_call_id.pop(parent_task_id, None)
if parent_task_id is not None
else None
)
self._on_started(
ns,
graph_name or None,
parent_task_id,
tool_call_id,
)
if parent_task_id is not None:
self._open[ns] = parent_task_id
def _record_dispatching_tool_call_id(self, data: dict[str, Any]) -> None:
"""Remember `task_id -> tool_call_id` if the task input matches
a recognized per-call tool-dispatch shape.
Shape detection and id extraction both live in
`_extract_dispatching_tool_call_id`; this method just records the
mapping under the dispatching task's own `id` so the lifecycle hook
can anchor a child subgraph back to the originating AI message
tool call when that subgraph's first task event arrives.
"""
task_id = data.get("id")
if not isinstance(task_id, str):
return
tool_call_id = _extract_dispatching_tool_call_id(data.get("input"))
if tool_call_id is None:
return
self._dispatching_tool_call_id[task_id] = tool_call_id
def _pop_terminal_transitions(
self, ns: tuple[str, ...], data: dict[str, Any]
) -> list[tuple[tuple[str, ...], SubgraphStatus, str | None]]:
"""Return and remove tracked children closed by this task result."""
) -> list[tuple[tuple[str, ...], LifecycleEvent, str | None, str]]:
"""Return and remove tracked children closed by this task result.
Each tuple is `(child_ns, status, error, parent_task_id)`.
`parent_task_id` is the dispatching task's id — the same id
we'd already paired with the namespace at `_on_started`.
"""
result_id = data.get("id")
if not result_id:
return []
transitions: list[tuple[tuple[str, ...], SubgraphStatus, str | None]] = []
for child_ns, parent_task_id in list(self._open.items()):
if child_ns[:-1] != ns or parent_task_id != result_id:
transitions: list[tuple[tuple[str, ...], LifecycleEvent, str | None, str]] = []
for child_ns, dispatching_task_id in list(self._open.items()):
if child_ns[:-1] != ns or dispatching_task_id != result_id:
continue
status, error = _terminal_from_result(data)
transitions.append((child_ns, status, error))
transitions.append((child_ns, status, error, dispatching_task_id))
del self._open[child_ns]
return transitions
def _handle_task_result(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
for child_ns, status, error in self._pop_terminal_transitions(ns, data):
self._on_terminal(child_ns, status, error)
for child_ns, status, error, parent_task_id in self._pop_terminal_transitions(
ns, data
):
self._on_terminal(child_ns, status, error, parent_task_id)
def finalize(self) -> None:
"""Emit `completed` for any tracked namespace still open at run end."""
for ns in list(self._open):
self._on_terminal(ns, "completed", None)
for ns, parent_task_id in list(self._open.items()):
self._on_terminal(ns, "completed", None, parent_task_id)
self._open.clear()
def fail(self, err: BaseException) -> None:
"""Emit terminal status for any tracked namespace still open."""
status, error_str = _status_from_exception(err)
for ns in list(self._open):
self._on_terminal(ns, status, error_str)
for ns, parent_task_id in list(self._open.items()):
self._on_terminal(ns, status, error_str, parent_task_id)
self._open.clear()
def _status_from_exception(err: BaseException) -> tuple[SubgraphStatus, str | None]:
def _status_from_exception(err: BaseException) -> tuple[LifecycleEvent, str | None]:
"""Map a run exception to a subgraph terminal status and error string."""
if isinstance(err, GraphDrained):
return "drained", None
@@ -490,7 +643,7 @@ def _status_from_exception(err: BaseException) -> tuple[SubgraphStatus, str | No
def _terminal_from_result(
payload: dict[str, Any],
) -> tuple[SubgraphStatus, str | None]:
) -> tuple[LifecycleEvent, str | None]:
"""Map a `TaskResultPayload` to a `(status, error)` pair.
Order matters: a result with both `error` and `interrupts` prefers
@@ -539,26 +692,37 @@ class LifecycleTransformer(_TasksLifecycleBase):
self,
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
parent_task_id: str | None,
tool_call_id: str | None = None,
) -> None:
if trigger_call_id is None:
# Without a task id we can't correlate a parent-result
if parent_task_id is None:
# Without a task id we can't correlate a dispatching-task-result
# event back to this namespace — skip the started payload
# and rely on finalize/fail to close.
return
payload: LifecyclePayload = {"event": "started", "namespace": list(ns)}
payload: LifecyclePayload = {
"event": "started",
"namespace": list(ns),
"parent_task_id": parent_task_id,
}
if graph_name:
payload["graph_name"] = graph_name
payload["trigger_call_id"] = trigger_call_id
if tool_call_id is not None:
payload["metadata"] = {"type": "tool_call", "tool_call_id": tool_call_id}
self._channel.push(payload)
def _on_terminal(
self,
ns: tuple[str, ...],
status: SubgraphStatus,
status: LifecycleEvent,
error: str | None,
parent_task_id: str,
) -> None:
payload: LifecyclePayload = {"event": status, "namespace": list(ns)}
payload: LifecyclePayload = {
"event": status,
"namespace": list(ns),
"parent_task_id": parent_task_id,
}
if error is not None:
payload["error"] = error
self._channel.push(payload)
@@ -611,7 +775,8 @@ class SubgraphTransformer(_TasksLifecycleBase):
self,
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
parent_task_id: str | None,
tool_call_id: str | None = None, # noqa: ARG002
) -> None:
if self._mux is None:
return
@@ -624,7 +789,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
mux=child_mux,
path=ns,
graph_name=graph_name,
trigger_call_id=trigger_call_id,
parent_task_id=parent_task_id,
)
self._handles[ns] = handle
self._log.push(handle)
@@ -632,8 +797,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
def _on_terminal(
self,
ns: tuple[str, ...],
status: SubgraphStatus,
status: LifecycleEvent,
error: str | None,
parent_task_id: str, # noqa: ARG002
) -> None:
handle = self._handles.get(ns)
if handle is None or not self._mark_terminal(handle, status, error):
@@ -643,8 +809,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
async def _aon_terminal(
self,
ns: tuple[str, ...],
status: SubgraphStatus,
status: LifecycleEvent,
error: str | None,
parent_task_id: str, # noqa: ARG002
) -> None:
handle = self._handles.get(ns)
if handle is None or not self._mark_terminal(handle, status, error):
@@ -654,7 +821,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
def _mark_terminal(
self,
handle: SubgraphRunStream | AsyncSubgraphRunStream,
status: SubgraphStatus,
status: LifecycleEvent,
error: str | None,
) -> bool:
"""Mark a handle terminal once. Returns True on first transition."""
@@ -669,7 +836,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
def _close_or_fail_handle(
self,
handle: SubgraphRunStream | AsyncSubgraphRunStream,
status: SubgraphStatus,
status: LifecycleEvent,
error: str | None,
) -> None:
if handle._mux is None or handle._mux._events._closed:
@@ -682,7 +849,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
async def _aclose_or_fail_handle(
self,
handle: SubgraphRunStream | AsyncSubgraphRunStream,
status: SubgraphStatus,
status: LifecycleEvent,
error: str | None,
) -> None:
if handle._mux is None or handle._mux._events._closed:
@@ -721,10 +888,15 @@ class SubgraphTransformer(_TasksLifecycleBase):
ns = tuple(event["params"]["namespace"])
data = event["params"]["data"]
if "result" in data:
for child_ns, status, error in self._pop_terminal_transitions(ns, data):
await self._aon_terminal(child_ns, status, error)
for (
child_ns,
status,
error,
parent_task_id,
) in self._pop_terminal_transitions(ns, data):
await self._aon_terminal(child_ns, status, error, parent_task_id)
else:
self._handle_task_start(ns)
self._handle_task_start(ns, data)
keep = False
else:
keep = True
@@ -736,9 +908,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
def _complete_open_handles(self) -> BaseException | None:
first_error: BaseException | None = None
for ns in list(self._open):
for ns, parent_task_id in list(self._open.items()):
try:
self._on_terminal(ns, "completed", None)
self._on_terminal(ns, "completed", None, parent_task_id)
except BaseException as e:
if first_error is None:
first_error = e
@@ -754,9 +926,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
async def _acomplete_open_handles(self) -> BaseException | None:
first_error: BaseException | None = None
for ns in list(self._open):
for ns, parent_task_id in list(self._open.items()):
try:
await self._aon_terminal(ns, "completed", None)
await self._aon_terminal(ns, "completed", None, parent_task_id)
except BaseException as e:
if first_error is None:
first_error = e
+17 -4
View File
@@ -1674,15 +1674,28 @@ async def test_arun_with_retry_timeout_observer_tracks_attempts():
async def test_arun_with_retry_timeout_observer_emits_progress_on_heartbeat():
events: list = []
# `_TimedAttemptScope.__init__` sets `_last_progress` to `time.monotonic()`,
# but the watchdog itself doesn't start running until after `wrap_config`
# and task scheduling — under CI load that gap can be large enough to eat
# the entire idle window before the task body's first await even runs. We
# defend against that by:
# 1. Using a generous idle_timeout so scheduling slack stays well within it.
# 2. Calling `runtime.heartbeat()` BEFORE the first sleep, which resets
# `_last_progress` to "now" the moment the task body actually starts.
idle_timeout_s = 1.0
class HeartbeatProc:
async def ainvoke(self, input, config):
runtime = config[CONF][CONFIG_KEY_RUNTIME]
runtime.heartbeat() # reset the idle clock at task-body entry
for _ in range(8):
await asyncio.sleep(0.05)
runtime.heartbeat()
return "ok"
task = _make_task(HeartbeatProc(), timeout=_idle_timeout(0.2), name="heartbeat")
task = _make_task(
HeartbeatProc(), timeout=_idle_timeout(idle_timeout_s), name="heartbeat"
)
task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append
assert await arun_with_retry(task, retry_policy=None) == "ok"
@@ -1691,13 +1704,13 @@ async def test_arun_with_retry_timeout_observer_emits_progress_on_heartbeat():
assert by_event[-1] == "finish"
progress = [ev for ev in events if ev.event == "progress"]
assert progress, "expected at least one progress event from heartbeat"
# Rate limit is `idle_timeout / 4` = 0.05s; with 8 heartbeats spaced ~0.05s
# we should see at most ~one progress event per heartbeat (well below 8).
# Rate limit is `idle_timeout / 4` = 0.25s; with the task running for
# ~400ms we expect 1–2 progress events (well below the 9 heartbeats).
assert len(progress) <= len(by_event)
for ev in progress:
assert ev.context.task_name == "heartbeat"
assert ev.context.attempt == 1
assert ev.context.idle_timeout_secs == 0.2
assert ev.context.idle_timeout_secs == idle_timeout_s
assert isinstance(ev.progress_at, datetime)
@@ -35,8 +35,16 @@ def _tasks_start(
*,
task_id: str,
name: str,
input: Any = None,
) -> dict[str, Any]:
"""Build a `tasks` ProtocolEvent carrying a TaskPayload (start)."""
"""Build a `tasks` ProtocolEvent carrying a TaskPayload (start).
Pass `input=[{"id": ..., "name": ..., "args": {...}}]` (the per-call
list shape `langchain.agents.create_agent` Send-fans out) or
`input={"tool_call": {"id": ..., ...}, ...}` (the dict envelope older
prebuilt agent paths emit) to exercise the lifecycle transformer's
`tool_call_id` mining for `lifecycle.started.metadata`.
"""
return {
"type": "event",
"method": "tasks",
@@ -46,7 +54,7 @@ def _tasks_start(
"data": {
"id": task_id,
"name": name,
"input": None,
"input": input,
"triggers": [],
},
},
@@ -123,7 +131,220 @@ def test_started_emitted_on_first_direct_child_task() -> None:
assert payload["event"] == "started"
assert payload["namespace"] == ["agent:abc123"]
assert payload["graph_name"] == "agent"
assert payload["trigger_call_id"] == "abc123"
assert payload["parent_task_id"] == "abc123"
def test_started_carries_metadata_for_dict_envelope_input() -> None:
"""When the dispatching task's `input` is a dict envelope with a
`tool_call` field (the shape older prebuilt agent paths Send-fan
out per call), the transformer mines `tool_call_id` from
`tool_call.id` and remembers it keyed by the dispatching task id.
When that task triggers a subgraph (the child's namespace ends in
`name:<dispatching_task_id>`), `lifecycle.started.metadata` carries
`{"type": "tool_call", "tool_call_id": ...}`. Identity correlation
still uses `parent_task_id`; `tool_call_id` is exposed so UI
consumers can anchor the lifecycle event back to the originating
AI message tool call. Args are deliberately NOT mined — they live
on the AIMessage and have a single source of truth there.
"""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="abc123",
name="tools",
input={
"tool_call": {
"id": "call_xyz",
"name": "task",
"args": {"subagent_type": "researcher"},
}
},
)
)
mux.push(_tasks_start(["agent:abc123"], task_id="t1", name="model"))
[payload] = _drain_lifecycle(mux)
assert payload["event"] == "started"
assert payload["parent_task_id"] == "abc123"
assert payload["metadata"] == {
"type": "tool_call",
"tool_call_id": "call_xyz",
}
def test_started_carries_metadata_for_list_shape_per_call_input() -> None:
"""`langchain.agents.create_agent` Send-fans out a per-call task
whose `input` is a single-element list of tool-call dicts:
`[{"id": ..., "name": ..., "args": {...}}]`. The transformer mines
`tool_call_id` exactly as for the dict envelope shape, so
`lifecycle.started.metadata` fires regardless of which agent factory
drove the dispatch.
"""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="abc123",
name="tools",
input=[
{
"id": "tc-1",
"name": "task",
"args": {"subagent_type": "researcher"},
}
],
)
)
mux.push(_tasks_start(["agent:abc123"], task_id="t1", name="model"))
[payload] = _drain_lifecycle(mux)
assert payload["event"] == "started"
assert payload["parent_task_id"] == "abc123"
assert payload["metadata"] == {"type": "tool_call", "tool_call_id": "tc-1"}
def test_started_carries_metadata_when_args_absent() -> None:
"""`tool_call_id` is the only field metadata needs; the dispatching
envelope can omit `args` entirely (or have non-dict args) and we
still produce a metadata as long as `id` is a string."""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="abc123",
name="tools",
input=[{"id": "tc-1", "name": "some_tool"}],
)
)
mux.push(_tasks_start(["agent:abc123"], task_id="t1", name="model"))
[payload] = _drain_lifecycle(mux)
assert payload["metadata"] == {"type": "tool_call", "tool_call_id": "tc-1"}
def test_list_shape_ignored_when_not_single_element() -> None:
"""Only single-element lists are recognized as the per-call shape;
a 0- or 2+-element list is some other batched/multi-call payload
and must not be mined."""
# Two-element list — not the per-call shape.
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="abc123",
name="tools",
input=[
{"id": "tc-1", "name": "task"},
{"id": "tc-2", "name": "task"},
],
)
)
mux.push(_tasks_start(["agent:abc123"], task_id="t1", name="model"))
[payload] = _drain_lifecycle(mux)
assert "metadata" not in payload
# Empty list.
mux2 = _build_lifecycle_mux()
mux2.push(_tasks_start([], task_id="def456", name="tools", input=[]))
mux2.push(_tasks_start(["agent:def456"], task_id="t1", name="model"))
[payload2] = _drain_lifecycle(mux2)
assert "metadata" not in payload2
def test_list_shape_robust_to_non_dict_or_missing_id() -> None:
"""Duck-typing safety: a single-element list whose element isn't a
dict, or whose dict has no string `id`, must not raise — it just
leaves `metadata` absent."""
# Element is not a dict.
mux = _build_lifecycle_mux()
mux.push(_tasks_start([], task_id="t-a", name="tools", input=["not-a-dict"]))
mux.push(_tasks_start(["agent:t-a"], task_id="t1", name="model"))
[payload] = _drain_lifecycle(mux)
assert "metadata" not in payload
# Element has no `id`.
mux2 = _build_lifecycle_mux()
mux2.push(_tasks_start([], task_id="t-b", name="tools", input=[{"name": "task"}]))
mux2.push(_tasks_start(["agent:t-b"], task_id="t1", name="model"))
[payload2] = _drain_lifecycle(mux2)
assert "metadata" not in payload2
# Element's `id` is not a string.
mux3 = _build_lifecycle_mux()
mux3.push(_tasks_start([], task_id="t-c", name="tools", input=[{"id": 123}]))
mux3.push(_tasks_start(["agent:t-c"], task_id="t1", name="model"))
[payload3] = _drain_lifecycle(mux3)
assert "metadata" not in payload3
def test_parallel_dispatches_attributed_to_correct_parent() -> None:
"""Two dispatching task envelopes in the same model turn each fan
out to their own child subgraph; each child's `metadata.tool_call_id`
must reflect its own dispatching envelope, not the other.
Defends the `parent_task_id` (pregel task id) join: that id is
parsed from the child namespace segment and is unique per Send,
so it disambiguates parallel dispatches 1:1. Both children share
the same `subagent_type` (in args, not on metadata) — only the
pregel task id can tell them apart, so the `tool_call_id` must
follow the pregel id, not anything from `args`.
"""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="parent_A",
name="tools",
input={
"tool_call": {
"id": "call_1",
"name": "task",
"args": {"subagent_type": "researcher"},
}
},
)
)
mux.push(
_tasks_start(
[],
task_id="parent_B",
name="tools",
input={
"tool_call": {
"id": "call_2",
"name": "task",
"args": {"subagent_type": "researcher"},
}
},
)
)
mux.push(_tasks_start(["agent:parent_A"], task_id="t1", name="model"))
mux.push(_tasks_start(["agent:parent_B"], task_id="t2", name="model"))
payloads = _drain_lifecycle(mux)
by_ns = {tuple(p["namespace"]): p for p in payloads}
assert by_ns[("agent:parent_A",)]["metadata"] == {
"type": "tool_call",
"tool_call_id": "call_1",
}
assert by_ns[("agent:parent_B",)]["metadata"] == {
"type": "tool_call",
"tool_call_id": "call_2",
}
def test_started_omits_metadata_for_structurally_triggered_subgraph() -> None:
"""Subgraphs triggered without a recognizable tool-call envelope on
the parent's input (Send with custom payloads, plain nested
`graph.invoke`, etc.) don't get a `metadata` field on
`lifecycle.started`."""
mux = _build_lifecycle_mux()
mux.push(_tasks_start(["agent:abc123"], task_id="t1", name="tool"))
[payload] = _drain_lifecycle(mux)
assert "metadata" not in payload
def test_started_dedup_on_repeat_namespace() -> None:
@@ -188,8 +409,12 @@ def test_completed_on_parent_task_result() -> None:
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
mux.push(_tasks_result([], task_id="abc", name="agent"))
events = [p["event"] for p in _drain_lifecycle(mux)]
assert events == ["started", "completed"]
payloads = _drain_lifecycle(mux)
assert [p["event"] for p in payloads] == ["started", "completed"]
# `parent_task_id` is required on every event for the same subgraph
# so consumers can correlate `started` ↔ terminal without joining
# via `namespace`.
assert all(p["parent_task_id"] == "abc" for p in payloads)
def test_failed_on_parent_task_result_with_error() -> None:
@@ -265,6 +490,54 @@ def test_fail_emits_failed_for_other_exceptions() -> None:
assert payloads[1]["error"] == "boom"
def test_parent_task_id_present_on_every_terminal_path() -> None:
"""Every exit path that emits a terminal event (parent-result with
error / interrupts, finalize sweep, fail sweep) must carry
`parent_task_id` so consumers can correlate the terminal event
back to its `started` without falling back to namespace joins."""
# Path 1: parent-result with error.
mux = _build_lifecycle_mux()
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
mux.push(_tasks_result([], task_id="abc", name="agent", error="boom"))
[_, terminal] = _drain_lifecycle(mux)
assert terminal["event"] == "failed"
assert terminal["parent_task_id"] == "abc"
# Path 2: parent-result with interrupts.
mux2 = _build_lifecycle_mux()
mux2.push(_tasks_start(["agent:def"], task_id="t1", name="tool"))
mux2.push(
_tasks_result([], task_id="def", name="agent", interrupts=[{"value": "pause"}])
)
[_, terminal2] = _drain_lifecycle(mux2)
assert terminal2["event"] == "interrupted"
assert terminal2["parent_task_id"] == "def"
# Path 3: finalize sweep (no parent result arrived).
mux3 = _build_lifecycle_mux()
mux3.push(_tasks_start(["agent:ghi"], task_id="t1", name="tool"))
mux3.close()
[_, terminal3] = _drain_lifecycle(mux3)
assert terminal3["event"] == "completed"
assert terminal3["parent_task_id"] == "ghi"
# Path 4: fail sweep with GraphInterrupt.
mux4 = _build_lifecycle_mux()
mux4.push(_tasks_start(["agent:jkl"], task_id="t1", name="tool"))
mux4.fail(GraphInterrupt())
[_, terminal4] = _drain_lifecycle(mux4)
assert terminal4["event"] == "interrupted"
assert terminal4["parent_task_id"] == "jkl"
# Path 5: fail sweep with generic exception.
mux5 = _build_lifecycle_mux()
mux5.push(_tasks_start(["agent:mno"], task_id="t1", name="tool"))
mux5.fail(RuntimeError("kaboom"))
[_, terminal5] = _drain_lifecycle(mux5)
assert terminal5["event"] == "failed"
assert terminal5["parent_task_id"] == "mno"
def test_unrelated_methods_pass_through() -> None:
"""Non-`tasks` events are not consumed and don't emit lifecycle."""
mux = _build_lifecycle_mux()
@@ -195,7 +195,7 @@ def test_handle_created_on_first_direct_child_task() -> None:
[handle] = _drain_subgraphs(mux)
assert handle.path == ("agent:abc",)
assert handle.graph_name == "agent"
assert handle.trigger_call_id == "abc"
assert handle.parent_task_id == "abc"
assert handle.status == "started"
_child_mux(handle) # mini-mux backed