From 22d4ccaa3bf7c9ea16b73c9c177bd86dc03fba17 Mon Sep 17 00:00:00 2001 From: Nick Hollon Date: Thu, 7 May 2026 23:47:52 -0400 Subject: [PATCH] feat(langgraph): expose tool_call_id on lifecycle.started.cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../langgraph/stream/transformers.py | 43 +++++++++++++------ .../test_stream_lifecycle_transformer.py | 23 +++++----- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/libs/langgraph/langgraph/stream/transformers.py b/libs/langgraph/langgraph/stream/transformers.py index 4421744a6..c91d77368 100644 --- a/libs/langgraph/langgraph/stream/transformers.py +++ b/libs/langgraph/langgraph/stream/transformers.py @@ -359,14 +359,20 @@ class LifecyclePayload(TypedDict, total=False): Shape: - - `{"type": "tool_call", "subagent_type": "", "description": ""}` - — set when the subgraph was triggered by a tool invocation routed - through `langgraph.prebuilt.ToolNode`. Mined from the per-call - dispatched task's `input.tool_call.args`. Lets consumers attribute - `lifecycle.started` to the invoking intent without needing the - model's `tool_call_id` (consumers join on the existing - `trigger_call_id` field, which is the unique pregel task id of the - invocation). + - `{"type": "tool_call", "subagent_type": "", "description": "", + "tool_call_id": ""}` — set when the subgraph was triggered by a tool + invocation routed through `langgraph.prebuilt.ToolNode`. Mined from the + per-call dispatched task's `input.tool_call`. `subagent_type` and + `description` describe the invoking intent; `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 per-call Send fan-out gives each tool_call its own pregel task, + so each `tool_call_id` here corresponds to exactly one `trigger_call_id`). + Identity-level correlation across lifecycle events for the same + invocation still uses `trigger_call_id`. + + Any field inside the dict is optional — partial metadata still produces + a `cause` if at least one field was extractable. Absent for structurally-triggered subgraphs (parallel branches via `Send` without ToolNode, nested `graph.invoke()`, etc.).""" @@ -509,26 +515,32 @@ class _TasksLifecycleBase(StreamTransformer): `[{"id": ..., "name": ..., "args": {...}}]`. Both funnel through the same args-mining code so - `subagent_type` and `description` are extracted identically. - Tool_call_id is intentionally not extracted — consumers join - on `trigger_call_id` (the pregel task id), which is the same - `task.id` we cache by here. + `subagent_type`, `description`, and `tool_call_id` are extracted + identically. Identity-level correlation still uses + `trigger_call_id` (the pregel task id, == this `task.id`); the + model-side `tool_call_id` is recorded so UI consumers can anchor + the lifecycle event back to the originating AI message tool call + (the per-call Send fan-out architecture gives each tool_call its + own per-call task, so tool_call_id ↔ trigger_call_id is 1:1 here). """ task_id = data.get("id") if not isinstance(task_id, str): return payload = data.get("input") args: Any + tool_call_id: Any = None if isinstance(payload, dict): tool_call = payload.get("tool_call") if not isinstance(tool_call, dict): return args = tool_call.get("args") + tool_call_id = tool_call.get("id") elif isinstance(payload, list) and len(payload) == 1: element = payload[0] if not isinstance(element, dict): return args = element.get("args") + tool_call_id = element.get("id") else: return if not isinstance(args, dict): @@ -540,6 +552,13 @@ class _TasksLifecycleBase(StreamTransformer): description = args.get("description") if isinstance(description, str): metadata["description"] = description + # `tool_call_id` rides along as anchoring metadata only when we + # already have descriptive intent (`subagent_type`/`description`). + # A per-call envelope without descriptive args is a non-subagent + # tool dispatch — it stays causeless, matching the structurally- + # triggered subgraph case. + if metadata and isinstance(tool_call_id, str): + metadata["tool_call_id"] = tool_call_id if metadata: self._invocation_metadata[task_id] = metadata diff --git a/libs/langgraph/tests/test_stream_lifecycle_transformer.py b/libs/langgraph/tests/test_stream_lifecycle_transformer.py index 86baeb105..ff52efbb0 100644 --- a/libs/langgraph/tests/test_stream_lifecycle_transformer.py +++ b/libs/langgraph/tests/test_stream_lifecycle_transformer.py @@ -138,13 +138,14 @@ def test_started_carries_cause_when_parent_input_has_invocation_metadata() -> No """When a parent task's `input` is a `ToolCallWithContext`-shaped envelope (`{"tool_call": {"args": {...}}, ...}`, the layout `langgraph.prebuilt.ToolNode` Send-fans out per call), the - transformer mines `subagent_type` and `description` from - `tool_call.args` and remembers them keyed by `parent_task_id`. + transformer mines `subagent_type`, `description`, and `tool_call_id` + from `tool_call` and remembers them keyed by `parent_task_id`. When that parent task triggers a subgraph (the child's namespace ends in `name:`), the `lifecycle.started` payload - carries `cause = {"type": "tool_call", "subagent_type": ..., "description": ...}`. - Consumers join on `trigger_call_id` (the pregel task id) for - identity; this dict is purely descriptive.""" + carries `cause = {"type": "tool_call", "subagent_type": ..., "description": ..., + "tool_call_id": ...}`. Identity-level correlation still uses + `trigger_call_id`; `tool_call_id` is exposed so UI consumers can + anchor the lifecycle event back to the originating AI message.""" mux = _build_lifecycle_mux() # Parent task at root ns whose input matches the Send envelope. mux.push( @@ -174,15 +175,15 @@ def test_started_carries_cause_when_parent_input_has_invocation_metadata() -> No "type": "tool_call", "subagent_type": "researcher", "description": "look up weather", + "tool_call_id": "call_xyz", } - # tool_call_id is intentionally not in cause — consumers join on - # trigger_call_id (the pregel task id) instead. - assert "tool_call_id" not in payload["cause"] def test_started_cause_with_description_but_no_subagent_type() -> None: """Partial invocation metadata (only `description`, or only `subagent_type`) - still produces a cause — both fields are optional within the dict.""" + still produces a cause — every field other than `type` is optional. + `tool_call_id` rides outside `args` and is extracted independently of + args content, so it shows up here even when args is sparse.""" mux = _build_lifecycle_mux() mux.push( _tasks_start( @@ -204,6 +205,7 @@ def test_started_cause_with_description_but_no_subagent_type() -> None: assert payload["cause"] == { "type": "tool_call", "description": "do a thing", + "tool_call_id": "call_xyz", } @@ -211,7 +213,7 @@ def test_started_carries_cause_for_list_shape_per_call_input() -> None: """langchain v1's `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 - `subagent_type` and `description` from `args` exactly as for the + `subagent_type`, `description`, and `tool_call_id` exactly as for the `ToolCallWithContext` dict envelope, so `lifecycle.started.cause` fires regardless of which agent factory drove the dispatch.""" mux = _build_lifecycle_mux() @@ -241,6 +243,7 @@ def test_started_carries_cause_for_list_shape_per_call_input() -> None: "type": "tool_call", "subagent_type": "researcher", "description": "Do X", + "tool_call_id": "tc-1", }