mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-02 14:28:46 +02:00
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:
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user