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.
This commit is contained in:
Nick Hollon
2026-05-07 23:47:52 -04:00
parent 4504f85157
commit 22d4ccaa3b
2 changed files with 44 additions and 22 deletions
+31 -12
View File
@@ -359,14 +359,20 @@ class LifecyclePayload(TypedDict, total=False):
Shape:
- `{"type": "tool_call", "subagent_type": "<name>", "description": "<text>"}`
— 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": "<name>", "description": "<text>",
"tool_call_id": "<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
@@ -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:<parent_task_id>`), 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",
}