diff --git a/libs/langgraph/langgraph/stream/transformers.py b/libs/langgraph/langgraph/stream/transformers.py index 1e20ed7a0..dbe63d341 100644 --- a/libs/langgraph/langgraph/stream/transformers.py +++ b/libs/langgraph/langgraph/stream/transformers.py @@ -343,16 +343,17 @@ def _parse_ns_segment(segment: str) -> tuple[str, str | None]: def _extract_per_call_args(payload: Any) -> tuple[dict[str, Any], str | None] | None: """Pull `(args, tool_call_id)` out of a per-call dispatched task's `input`. - Two shapes are recognised; both are duck-typed so 3rd-party tool - runners that mimic the layout participate without importing prebuilt - types: + 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. `ToolCallWithContext`-style envelope used by - `langgraph.prebuilt.ToolNode` Send-fan-out: - `{"tool_call": {"id": ..., "args": {...}, ...}, ...}`. - 2. Single-element list of tool-call dicts used by langchain v1's - `create_agent` Send-fan-out: - `[{"id": ..., "name": ..., "args": {...}}]`. + 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. Returns `None` if the payload doesn't match either shape or its `args` isn't a dict. `tool_call_id` is `None` when the envelope @@ -395,10 +396,11 @@ class LifecyclePayload(TypedDict, total=False): Shape: - `{"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 + "tool_call_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). Mined from the per-call task's `input` — + see `_extract_per_call_args` for the recognised shapes. `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, @@ -410,7 +412,7 @@ class LifecyclePayload(TypedDict, total=False): a `cause` if at least one field was extractable. Absent for structurally-triggered subgraphs (parallel branches via - `Send` without ToolNode, nested `graph.invoke()`, etc.).""" + `Send` with non-tool-call payloads, nested `graph.invoke()`, etc.).""" error: NotRequired[str] @@ -451,12 +453,11 @@ class _TasksLifecycleBase(StreamTransformer): # Maps tracked namespace -> task_id of the parent task whose # `TaskResultPayload` will close it. self._open: dict[tuple[str, ...], str] = {} - # Maps task_id -> invocation metadata for tasks whose `input` looked - # like a `{"tool_call": {...}, ...}` envelope (the shape - # `langgraph.prebuilt.ToolNode` Send-fans out via - # `ToolCallWithContext`). The lifecycle hook joins on this - # when a child subgraph fires its first task event so it can - # attribute the invocation to the model tool call's args. + # Maps task_id -> invocation metadata for tasks whose `input` matched + # a recognized per-call tool-dispatch shape (see `_extract_per_call_args` + # for the accepted layouts). The lifecycle hook joins on this when a + # child subgraph fires its first task event so it can attribute the + # invocation to the model tool call's args. self._invocation_metadata: dict[str, dict[str, str]] = {} # --- Template-method hooks (subclass overrides) --- diff --git a/libs/langgraph/tests/test_stream_lifecycle_transformer.py b/libs/langgraph/tests/test_stream_lifecycle_transformer.py index 251e5a675..eaf16b4df 100644 --- a/libs/langgraph/tests/test_stream_lifecycle_transformer.py +++ b/libs/langgraph/tests/test_stream_lifecycle_transformer.py @@ -39,11 +39,12 @@ def _tasks_start( ) -> dict[str, Any]: """Build a `tasks` ProtocolEvent carrying a TaskPayload (start). - Pass `input={"tool_call": {"args": {...}}}` (or any envelope with - that shape) to exercise the lifecycle transformer's input mining of - invocation-intent metadata (`subagent_type`, `description`) — this - mirrors the `ToolCallWithContext` payload `langgraph.prebuilt.ToolNode` - Send-fans out per tool call. + Pass `input=[{"id": ..., "name": ..., "args": {...}}]` (the per-call + list shape `langchain.agents.create_agent` Send-fans out) or + `input={"tool_call": {"args": {...}}, ...}` (the dict envelope older + prebuilt agent paths emit) to exercise the lifecycle transformer's + input mining of invocation-intent metadata (`subagent_type`, + `description`). """ return { "type": "event", @@ -135,14 +136,14 @@ def test_started_emitted_on_first_direct_child_task() -> None: def test_started_carries_cause_when_parent_input_has_invocation_metadata() -> None: - """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`, `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": ..., + """When a parent task's `input` is a dict envelope with a `tool_call` + field (`{"tool_call": {"args": {...}}, ...}`, the layout older + prebuilt agent paths Send-fan out per call), the 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": ..., "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.""" @@ -210,12 +211,12 @@ def test_started_cause_with_description_but_no_subagent_type() -> None: 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: + """`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 - `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.""" + `subagent_type`, `description`, and `tool_call_id` exactly as for + the dict envelope shape, so `lifecycle.started.cause` fires + regardless of which agent factory drove the dispatch.""" mux = _build_lifecycle_mux() mux.push( _tasks_start(